merge main

This commit is contained in:
martabal 2023-08-28 14:32:13 +02:00
parent 77fb3e9ce2
commit 8b7a4f2169
No known key found for this signature in database
GPG key ID: C00196E3148A52BD
363 changed files with 10881 additions and 2442 deletions

View file

@ -45,7 +45,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.10.5"
flutter-version: "3.13.0"
cache: true
- name: Create the Keystore

View file

@ -42,7 +42,7 @@ jobs:
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.9.1
uses: docker/setup-buildx-action@v2.10.0
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761
@ -126,7 +126,7 @@ jobs:
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.9.1
uses: docker/setup-buildx-action@v2.10.0
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761

View file

@ -23,7 +23,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.10.5"
flutter-version: "3.13.0"
- name: Install dependencies
run: dart pub get

View file

@ -149,7 +149,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.10.5"
flutter-version: "3.13.0"
- name: Run tests
working-directory: ./mobile
run: flutter test -j 1
@ -171,6 +171,7 @@ 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

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.73.0
* The version of the OpenAPI document: 1.75.2
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.73.0
* The version of the OpenAPI document: 1.75.2
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.73.0
* The version of the OpenAPI document: 1.75.2
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.73.0
* The version of the OpenAPI document: 1.75.2
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

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 { ServerVersionReponseDto, UserResponseDto } from 'src/api/open-api';
import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
export abstract class BaseCommand {
protected sessionService!: SessionService;
protected immichApi!: ImmichApi;
protected deviceId!: string;
protected user!: UserResponseDto;
protected serverVersion!: ServerVersionReponseDto;
protected serverVersion!: ServerVersionResponseDto;
protected configDir;
protected authPath;

View file

@ -100,8 +100,8 @@ services:
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
logging:
driver: none
# remove this to get debug messages
- GLOG_minloglevel=1
volumes:
- tsdata:/data

View file

@ -68,8 +68,8 @@ services:
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
logging:
driver: none
# remove this to get debug messages
- GLOG_minloglevel=1
volumes:
- tsdata:/data
restart: always

View file

@ -54,6 +54,8 @@ 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

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_URL=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_ENABLED=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_URL=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_ENABLED=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)?

View file

@ -0,0 +1,91 @@
# 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.

View file

@ -132,7 +132,6 @@ 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

View file

@ -1,3 +1,7 @@
---
sidebar_position: 90
---
# Environment Variables
## Docker Compose
@ -22,6 +26,7 @@ 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
@ -50,13 +55,14 @@ 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_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 |
| 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 |
:::info

View file

@ -1,5 +1,5 @@
---
sidebar_position: 100
sidebar_position: 80
---
import RegisterAdminUser from '../partials/_register-admin.md';

View file

@ -10,8 +10,9 @@ 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 ./
COPY poetry.lock pyproject.toml requirements.txt ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
RUN pip install --no-deps -r requirements.txt
FROM python:3.11.4-slim-bullseye@sha256:91d194f58f50594cda71dcd2e8fdefd90e7ecc57d07823813b67c8521e565dcd

View file

@ -1,3 +1,4 @@
import os
from pathlib import Path
from pydantic import BaseSettings
@ -8,25 +9,31 @@ from .schemas import ModelType
class Settings(BaseSettings):
cache_folder: str = "/cache"
classification_model: str = "microsoft/resnet-50"
clip_image_model: str = "clip-ViT-B-32"
clip_text_model: str = "clip-ViT-B-32"
clip_image_model: str = "ViT-B-32::openai"
clip_text_model: str = "ViT-B-32::openai"
facial_recognition_model: str = "buffalo_l"
min_tag_score: float = 0.9
eager_startup: bool = True
eager_startup: bool = False
model_ttl: int = 0
host: str = "0.0.0.0"
port: int = 3003
workers: int = 1
min_face_score: float = 0.7
test_full: bool = False
request_threads: int = os.cpu_count() or 4
model_inter_op_threads: int = 1
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)
return Path(settings.cache_folder) / model_type.value / model_name.translate(_clean_name)
settings = Settings()

View file

@ -1,4 +1,6 @@
import asyncio
import os
from concurrent.futures import ThreadPoolExecutor
from io import BytesIO
from typing import Any
@ -8,6 +10,8 @@ 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 (
@ -25,19 +29,21 @@ 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 = [
(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),
models: list[tuple[str, ModelType, dict[str, Any]]] = [
(settings.classification_model, ModelType.IMAGE_CLASSIFICATION, {}),
(settings.clip_image_model, ModelType.CLIP, {"mode": "vision"}),
(settings.clip_text_model, ModelType.CLIP, {"mode": "text"}),
(settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION, {}),
]
# Get all models
for model_name, model_type in models:
await app.state.model_cache.get(model_name, model_type, eager=settings.eager_startup)
for model_name, model_type, model_kwargs in models:
await app.state.model_cache.get(model_name, model_type, eager=settings.eager_startup, **model_kwargs)
@app.on_event("startup")
@ -46,11 +52,16 @@ 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(...)) -> cv2.Mat:
def dep_cv_image(byte_image: bytes = Body(...)) -> np.ndarray[int, np.dtype[Any]]:
byte_image_np = np.frombuffer(byte_image, np.uint8)
return cv2.imdecode(byte_image_np, cv2.IMREAD_COLOR)
@ -74,7 +85,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 = model.predict(image)
labels = await predict(model, image)
return labels
@ -86,8 +97,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)
embedding = model.predict(image)
model = await app.state.model_cache.get(settings.clip_image_model, ModelType.CLIP, mode="vision")
embedding = await predict(model, image)
return embedding
@ -97,8 +108,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)
embedding = model.predict(payload.text)
model = await app.state.model_cache.get(settings.clip_text_model, ModelType.CLIP, mode="text")
embedding = await predict(model, payload.text)
return embedding
@ -111,10 +122,14 @@ 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 = model.predict(image)
faces = await predict(model, image)
return faces
async def predict(model: InferenceModel, inputs: Any) -> Any:
return await asyncio.get_running_loop().run_in_executor(app.state.thread_pool, model.predict, inputs)
if __name__ == "__main__":
is_dev = os.getenv("NODE_ENV") == "development"
uvicorn.run(

View file

@ -1,3 +1,3 @@
from .clip import CLIPSTEncoder
from .clip import CLIPEncoder
from .facial_recognition import FaceRecognizer
from .image_classification import ImageClassifier

View file

@ -1,14 +1,17 @@
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
from ..config import get_cache_dir, settings
from ..schemas import ModelType
@ -16,12 +19,31 @@ class InferenceModel(ABC):
_model_type: ModelType
def __init__(
self, model_name: str, cache_dir: Path | str | None = None, eager: bool = True, **model_kwargs: Any
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,
) -> 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):
@ -30,6 +52,7 @@ 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:
@ -39,6 +62,7 @@ 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)
@ -89,3 +113,14 @@ 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)

View file

@ -46,7 +46,7 @@ class ModelCache:
model: The requested model.
"""
key = self.cache.build_key(model_name, model_type.value)
key = f"{model_name}{model_type.value}{model_kwargs.get('mode', '')}"
async with OptimisticLock(self.cache, key) as lock:
model = await self.cache.get(key)
if model is None:

View file

@ -1,31 +1,141 @@
from typing import Any
import os
import zipfile
from typing import Any, Literal
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 sentence_transformers import SentenceTransformer
from sentence_transformers.util import snapshot_download
from torchvision.transforms import CenterCrop, Compose, Normalize, Resize, ToTensor
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 CLIPSTEncoder(InferenceModel):
class CLIPEncoder(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:
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"],
)
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])
def _load(self, **model_kwargs: Any) -> None:
self.model = SentenceTransformer(
self.model_name,
cache_folder=self.cache_dir.as_posix(),
**model_kwargs,
)
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)
def _predict(self, image_or_text: Image | str) -> list[float]:
return self.model.encode(image_or_text).tolist()
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),
),
]
)

View file

@ -4,6 +4,7 @@ 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
@ -42,15 +43,31 @@ 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(det_file.as_posix())
self.rec_model = ArcFaceONNX(rec_file.as_posix())
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.prepare(
ctx_id=-1,
ctx_id=0,
det_thresh=self.min_score,
input_size=(640, 640),
)
self.rec_model.prepare(ctx_id=-1)
self.rec_model.prepare(ctx_id=0)
def _predict(self, image: cv2.Mat) -> list[dict[str, Any]]:
bboxes, kpss = self.det_model.detect(image)

View file

@ -2,8 +2,10 @@ 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.pipelines import pipeline
from transformers import AutoImageProcessor
from ..config import settings
from ..schemas import ModelType
@ -25,15 +27,34 @@ 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"]
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,
)
def _load(self, **model_kwargs: Any) -> None:
self.model = pipeline(
self.model_type.value,
self.model_name,
model_kwargs={"cache_dir": self.cache_dir, **model_kwargs},
)
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,
)
def _predict(self, image: Image) -> list[str]:
predictions: list[dict[str, Any]] = self.model(image) # type: ignore

View file

@ -1,17 +1,20 @@
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 CLIPSTEncoder
from .models.clip import CLIPEncoder
from .models.facial_recognition import FaceRecognizer
from .models.image_classification import ImageClassifier
from .schemas import ModelType
@ -72,45 +75,47 @@ class TestCLIP:
embedding = np.random.rand(512).astype(np.float32)
def test_eager_init(self, mocker: MockerFixture) -> None:
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")
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")
assert clip_model.model_name == "test_model_name"
assert clip_model.model_name == "ViT-B-32::openai"
mock_load.assert_called_once_with(test_arg="test_arg")
def test_lazy_init(self, mocker: MockerFixture) -> None:
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")
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")
assert clip_model.model_name == "test_model_name"
assert clip_model.model_name == "ViT-B-32::openai"
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(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
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"
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.model.encode.assert_called_once()
clip_encoder.vision_model.run.assert_called_once()
def test_basic_text(self, mocker: MockerFixture) -> None:
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
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"
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.model.encode.assert_called_once()
clip_encoder.text_model.run.assert_called_once()
class TestFaceRecognition:
@ -254,3 +259,13 @@ 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

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.73.0"
version = "1.75.2"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"
@ -13,7 +13,6 @@ 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"
@ -22,6 +21,15 @@ 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"
@ -62,13 +70,20 @@ warn_untyped_fields = true
[[tool.mypy.overrides]]
module = [
"huggingface_hub",
"transformers.pipelines",
"transformers",
"cv2",
"insightface.model_zoo",
"insightface.utils.face_align",
"insightface.utils.storage",
"sentence_transformers",
"sentence_transformers.util",
"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",
"aiocache.backends.memory",
"aiocache.lock",
"aiocache.plugins"

View file

@ -0,0 +1,2 @@
# requirements to be installed with `--no-deps` flag
clip-server==0.8.*

View file

@ -1,4 +1,4 @@
{
"flutterSdkVersion": "3.10.5",
"flutterSdkVersion": "3.13.0",
"flavors": {}
}

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 23
minSdkVersion 26
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName

View file

@ -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" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
@ -64,6 +64,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<queries>
<intent>

View file

@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 96,
"android.injected.version.name" => "1.73.0",
"android.injected.version.code" => 98,
"android.injected.version.name" => "1.75.2",
}
)
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')

View file

@ -300,5 +300,21 @@
"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"
}
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
"translated_text_options": "Options",
"map_no_assets_in_bounds": "No photos in this area",
"map_zoom_to_see_photos": "Zoom out to see photos",
"map_settings_dialog_title": "Map Settings",
"map_settings_dark_mode": "Dark mode",
"map_settings_only_show_favorites": "Show Favorite Only",
"map_settings_only_relative_range": "Date range",
"map_settings_dialog_cancel": "Cancel",
"map_settings_dialog_save": "Save",
"map_cannot_get_user_location": "Cannot get user's location",
"map_location_service_disabled_title": "Location Service disabled",
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
"map_no_location_permission_title": "Location Permission denied",
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
"map_location_dialog_cancel": "Cancel",
"map_location_dialog_yes": "Yes"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -20,6 +20,8 @@ PODS:
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- geolocator_apple (1.2.0):
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- integration_test (0.0.1):
@ -33,7 +35,7 @@ PODS:
- FlutterMacOS
- path_provider_ios (0.0.1):
- Flutter
- permission_handler_apple (9.0.4):
- permission_handler_apple (9.1.1):
- Flutter
- photo_manager (2.0.0):
- Flutter
@ -53,7 +55,7 @@ PODS:
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- wakelock (0.0.1):
- wakelock_plus (0.0.1):
- Flutter
DEPENDENCIES:
@ -65,6 +67,7 @@ DEPENDENCIES:
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
@ -78,7 +81,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 (from `.symlinks/plugins/wakelock/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
@ -104,6 +107,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_web_auth/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
integration_test:
@ -130,8 +135,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/ios"
wakelock:
:path: ".symlinks/plugins/wakelock/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
@ -141,26 +146,27 @@ SPEC CHECKSUMS:
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
geolocator_apple: cc556e6844d508c95df1e87e3ea6fa4e58c50401
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
integration_test: 13825b8a9334a850581300559b8839134b124670
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce
permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
video_player_avfoundation: 81e49bb3d9fb63dccf9fa0f6d877dc3ddbeac126
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
COCOAPODS: 1.12.1
COCOAPODS: 1.11.3

View file

@ -171,7 +171,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1300;
LastUpgradeCheck = 1430;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View file

@ -83,8 +83,6 @@
</dict>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSMicrophoneUsageDescription</key>

View file

@ -1,4 +1,4 @@
#!/bin/sh
#!/usr/bin/env sh
# The default execution directory of this script is the ci_scripts directory.
cd $CI_WORKSPACE/mobile

View file

@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.73.0"
version_number: "1.75.2"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View file

@ -63,11 +63,15 @@ Future<void> initApp() async {
FlutterError.onError = (details) {
FlutterError.presentError(details);
log.severe(details.toString(), details, details.stack);
log.severe(
'Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}',
details,
details.stack,
);
};
PlatformDispatcher.instance.onError = (error, stack) {
log.severe(error.toString(), error, stack);
log.severe('Catch all error: ${error.toString()} - $error', error, stack);
return true;
};
}
@ -139,6 +143,10 @@ 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;
}
}

View file

@ -56,6 +56,16 @@ 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();

View file

@ -348,6 +348,26 @@ 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,

View file

@ -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(),
],
)
),
],
),
),

View file

@ -69,6 +69,11 @@ 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)

View file

@ -39,7 +39,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
void onDeleteAlbumPressed() async {
deleteAlbum() async {
ImmichLoadingOverlayController.appLoader.show();
final bool success;
@ -65,6 +65,52 @@ 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();
@ -152,43 +198,61 @@ 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: 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(),
),
],
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,
],
),
),
);
},
@ -217,6 +281,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
toastType: ToastType.error,
);
}
titleFocusNode.unfocus();
},
icon: const Icon(Icons.check_rounded),
splashRadius: 25,

View file

@ -84,6 +84,11 @@ 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,
),
),
);
}

View file

@ -0,0 +1,205 @@
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(),
],
),
);
}
}

View file

@ -17,6 +17,7 @@ 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 {
@ -116,7 +117,7 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildControlButton(Album album) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8),
padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 16),
child: SizedBox(
height: 40,
child: ListView(
@ -141,7 +142,7 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildTitle(Album album) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
padding: const EdgeInsets.only(left: 8, right: 8, top: 24),
child: userId == album.ownerId && album.isRemote
? AlbumViewerEditableTitle(
album: album,
@ -172,7 +173,6 @@ 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,7 +180,34 @@ class AlbumViewerPage extends HookConsumerWidget {
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
);
}
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,
),
),
);
@ -193,33 +220,7 @@ class AlbumViewerPage extends HookConsumerWidget {
children: [
buildTitle(album),
if (album.assets.isNotEmpty == true) buildAlbumDateRange(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,
),
),
if (album.shared) buildSharedUserIconsRow(album),
],
);
}

View file

@ -73,9 +73,12 @@ class AssetSelectionPage extends HookConsumerWidget {
AutoRouter.of(context)
.popForced<AssetSelectionPageResult>(payload);
},
child: const Text(
child: Text(
"share_add",
style: TextStyle(fontWeight: FontWeight.bold),
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
).tr(),
),
],

View file

@ -30,7 +30,8 @@ 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 {
@ -248,8 +249,9 @@ class CreateAlbumPage extends HookConsumerWidget {
: null,
child: Text(
'create_shared_album_page_create'.tr(),
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),

View file

@ -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,
),
)
),
],
),
);

View file

@ -7,6 +7,7 @@ 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;
@ -35,10 +36,8 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
),
);
} else {
return CircleAvatar(
backgroundImage:
const AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
return UserCircleAvatar(
user: user,
);
}
}
@ -103,7 +102,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
} else {
sharedUsersList.value = {
...sharedUsersList.value,
users[index]
users[index],
};
}
},
@ -136,7 +135,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
"share_add",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
).tr(),
)
),
],
),
body: suggestedShareUsers.when(

View file

@ -10,6 +10,7 @@ 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})
@ -56,10 +57,8 @@ class SelectUserForSharingPage extends HookConsumerWidget {
),
);
} else {
return CircleAvatar(
backgroundImage:
const AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
return UserCircleAvatar(
user: user,
);
}
}
@ -124,7 +123,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
} else {
sharedUsersList.value = {
...sharedUsersList.value,
users[index]
users[index],
};
}
},
@ -164,7 +163,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
// color: Theme.of(context).primaryColor,
),
).tr(),
)
),
],
),
body: suggestedShareUsers.when(

View file

@ -160,7 +160,7 @@ class SharingPage extends HookConsumerWidget {
maxLines: 1,
).tr(),
),
)
),
],
),
);

View file

@ -2,14 +2,12 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class ArchivePage extends HookConsumerWidget {
const ArchivePage({super.key});
@ -68,30 +66,18 @@ class ArchivePage extends HookConsumerWidget {
: () async {
processing.value = true;
try {
if (selection.value.isNotEmpty) {
await ref
.watch(assetProvider.notifier)
.toggleArchive(
selection.value.toList(),
false,
);
final assetOrAssets = selection.value.length > 1
? 'assets'
: 'asset';
ImmichToast.show(
context: context,
msg:
'Moved ${selection.value.length} $assetOrAssets to library',
gravity: ToastGravity.CENTER,
);
}
await handleArchiveAssets(
ref,
context,
selection.value.toList(),
shouldArchive: false,
);
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
},
)
),
],
),
),
@ -124,7 +110,7 @@ class ArchivePage extends HookConsumerWidget {
),
if (selectionEnabledHook.value) buildBottomBar(),
if (processing.value)
const Center(child: ImmichLoadingIndicator())
const Center(child: ImmichLoadingIndicator()),
],
),
),

View file

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:latlong2/latlong.dart';
@ -16,16 +17,35 @@ class ExifBottomSheet extends HookConsumerWidget {
const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
bool get showMap =>
bool get hasCoordinates =>
asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null;
Future<Uri> _createCoordinatesUri(double latitude, double longitude) async {
const zoomLevel = 5;
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;
if (Platform.isAndroid) {
Uri uri = Uri(
scheme: 'geo',
host: '$latitude,$longitude',
queryParameters: {'z': '$zoomLevel', 'q': '$latitude,$longitude'},
queryParameters: {
'z': '$zoomLevel',
'q': '$latitude,$longitude($formattedDateTime)',
},
);
if (await canLaunchUrl(uri)) {
return uri;
@ -33,16 +53,20 @@ class ExifBottomSheet extends HookConsumerWidget {
} else if (Platform.isIOS) {
var params = {
'll': '$latitude,$longitude',
'q': '$latitude, $longitude',
'q': formattedDateTime,
'z': '$zoomLevel',
};
Uri uri = Uri.https('maps.apple.com', '/', params);
if (!await canLaunchUrl(uri)) {
if (await canLaunchUrl(uri)) {
return uri;
}
}
return Uri.https(
'www.google.com',
'/maps/place/$latitude,$longitude/@$latitude,$longitude,${zoomLevel}z',
return Uri(
scheme: 'https',
host: 'openstreetmap.org',
queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
fragment: 'map=$zoomLevel/$latitude/$longitude',
);
}
@ -57,67 +81,35 @@ class ExifBottomSheet extends HookConsumerWidget {
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: LayoutBuilder(
builder: (context, constraints) {
return Container(
height: 150,
width: constraints.maxWidth,
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(15)),
return MapThumbnail(
coords: LatLng(
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
child: FlutterMap(
options: MapOptions(
interactiveFlags: InteractiveFlag.none,
center: LatLng(
height: 150,
zoom: 16.0,
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
zoom: 16.0,
onTap: (tapPosition, latLong) async {
if (exifInfo != null &&
exifInfo.latitude != null &&
exifInfo.longitude != null) {
launchUrl(
await _createCoordinatesUri(
exifInfo.latitude!,
exifInfo.longitude!,
),
);
}
},
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'),
),
),
nonRotatedChildren: [
RichAttributionWidget(
attributions: [
TextSourceAttribution(
'OpenStreetMap contributors',
onTap: () => launchUrl(
Uri.parse('https://openstreetmap.org/copyright'),
),
),
],
),
],
children: [
TileLayer(
urlTemplate:
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: const ['a', 'b', 'c'],
),
MarkerLayer(
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'),
),
),
],
),
],
),
],
onTap: (tapPosition, latLong) async {
Uri? uri = await _createCoordinatesUri();
if (uri == null) {
return;
}
debugPrint('Opening Map Uri: $uri');
launchUrl(uri);
},
);
},
),
@ -151,7 +143,7 @@ class ExifBottomSheet extends HookConsumerWidget {
buildLocation() {
// Guard no lat/lng
if (!showMap) {
if (!hasCoordinates) {
return Container();
}
@ -199,7 +191,7 @@ class ExifBottomSheet extends HookConsumerWidget {
Text(
"${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
style: const TextStyle(fontSize: 12),
)
),
],
),
],
@ -207,12 +199,8 @@ class ExifBottomSheet extends HookConsumerWidget {
}
buildDate() {
final fileCreatedAt = asset.fileCreatedAt.toLocal();
final date = DateFormat.yMMMEd().format(fileCreatedAt);
final time = DateFormat.jm().format(fileCreatedAt);
return Text(
'$date$time',
formattedDateTime,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
@ -306,7 +294,7 @@ class ExifBottomSheet extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: showMap ? 5 : 0,
flex: hasCoordinates ? 5 : 0,
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: buildLocation(),
@ -336,7 +324,7 @@ class ExifBottomSheet extends HookConsumerWidget {
if (asset.isRemote) DescriptionInput(asset: asset),
const SizedBox(height: 8.0),
buildLocation(),
SizedBox(height: showMap ? 16.0 : 0.0),
SizedBox(height: hasCoordinates ? 16.0 : 0.0),
buildDetail(),
const SizedBox(height: 50),
],

View file

@ -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(),
],
);
}

View file

@ -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/wakelock.dart';
import 'package:wakelock_plus/wakelock_plus.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) {
Wakelock.enable();
WakelockPlus.enable();
widget.onPlaying?.call();
} else if (!videoPlayerController.value.isPlaying) {
Wakelock.disable();
WakelockPlus.disable();
widget.onPaused?.call();
}
if (videoPlayerController.value.position ==
videoPlayerController.value.duration) {
Wakelock.disable();
WakelockPlus.disable();
widget.onVideoEnded();
}
}
@ -155,8 +155,8 @@ class _VideoPlayerState extends State<VideoPlayer> {
Future<void> initializePlayer() async {
try {
videoPlayerController = widget.file == null
? VideoPlayerController.network(
widget.url!,
? VideoPlayerController.networkUrl(
Uri.parse(widget.url!),
httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
)
: VideoPlayerController.file(widget.file!);
@ -210,8 +210,7 @@ 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(),
),

View file

@ -90,7 +90,7 @@ class BackgroundService {
requireUnmetered,
requireCharging,
triggerUpdateDelay,
triggerMaxDelay
triggerMaxDelay,
],
);
return ok;

View file

@ -511,7 +511,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
deviceAssetId
deviceAssetId,
},
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
);

View file

@ -149,16 +149,30 @@ 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();
Set<AssetEntity> allUploadAssets = allManualUploads
.where((e) => e.isLocal && e.local != null)
.map((e) => e.local!)
.toSet();
// 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();
if (allUploadAssets.isEmpty) {
debugPrint("[_startUpload] No Assets to upload - Abort Process");
@ -213,7 +227,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(
@ -237,32 +251,29 @@ 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();
}
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
_handleAppInActivity();
await _localNotificationService.closeNotification(
LocalNotificationService.manualUploadDetailedNotificationID,
);
await _backupProvider.notifyBackgroundServiceCanRun();
return false;
return !hasErrors;
}
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();
}
}

View file

@ -218,7 +218,18 @@ class BackupService {
bool anyErrors = false;
final List<String> duplicatedAssetIds = [];
for (var entity in assetList) {
// Upload images before video assets
// these are further sorted by using their creation date so the upload goes as follows
// older images -> latest images -> older videos -> latest videos
List<AssetEntity> sortedAssets = assetList.sorted(
(a, b) {
final cmp = a.typeInt - b.typeInt;
if (cmp != 0) return cmp;
return a.createDateTime.compareTo(b.createDateTime);
},
);
for (var entity in sortedAssets) {
try {
if (entity.type == AssetType.video) {
file = await entity.originFile;
@ -248,9 +259,10 @@ class BackupService {
req.fields['deviceAssetId'] = entity.id;
req.fields['deviceId'] = deviceId;
req.fields['fileCreatedAt'] = entity.createDateTime.toIso8601String();
req.fields['fileCreatedAt'] =
entity.createDateTime.toUtc().toIso8601String();
req.fields['fileModifiedAt'] =
entity.modifiedDateTime.toIso8601String();
entity.modifiedDateTime.toUtc().toIso8601String();
req.fields['isFavorite'] = entity.isFavorite.toString();
req.fields['duration'] = entity.videoDuration.toString();

View file

@ -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,
),
)
),
],
),
),

View file

@ -212,7 +212,7 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
Text(
" ${uploadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12),
)
),
],
),
),

View file

@ -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(

View file

@ -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/wakelock.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class BackupControllerPage extends HookConsumerWidget {
const BackupControllerPage({Key? key}) : super(key: key);
@ -114,7 +114,7 @@ class BackupControllerPage extends HookConsumerWidget {
);
return;
}
Wakelock.enable();
WakelockPlus.enable();
const limit = 100;
final toDelete = await ref
.read(backupVerificationServiceProvider)
@ -140,7 +140,7 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
} finally {
Wakelock.disable();
WakelockPlus.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(),
],
),
),

View file

@ -129,7 +129,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
],
),
),
)
),
],
),
),

View file

@ -2,13 +2,11 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class FavoritesPage extends HookConsumerWidget {
const FavoritesPage({Key? key}) : super(key: key);
@ -44,16 +42,11 @@ class FavoritesPage extends HookConsumerWidget {
void unfavorite() async {
try {
if (selection.value.isNotEmpty) {
await ref.watch(assetProvider.notifier).toggleFavorite(
selection.value.toList(),
false,
);
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg:
'Removed ${selection.value.length} $assetOrAssets from favorites',
gravity: ToastGravity.CENTER,
await handleFavoriteAssets(
ref,
context,
selection.value.toList(),
shouldFavorite: false,
);
}
} finally {
@ -83,7 +76,7 @@ class FavoritesPage extends HookConsumerWidget {
style: TextStyle(fontSize: 14),
),
onTap: processing.value ? null : unfavorite,
)
),
],
),
),
@ -108,7 +101,7 @@ class FavoritesPage extends HookConsumerWidget {
selectionActive: selectionEnabledHook.value,
listener: selectionListener,
),
if (selectionEnabledHook.value) buildBottomBar()
if (selectionEnabledHook.value) buildBottomBar(),
],
),
),

View file

@ -57,7 +57,7 @@ class GroupDividerTitle extends ConsumerWidget {
Icons.check_circle_outline_rounded,
color: Colors.grey,
),
)
),
],
),
);

View file

@ -30,6 +30,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
final void Function(ItemPosition start, ItemPosition end)?
visibleItemsListener;
final Widget? topWidget;
final bool shrinkWrap;
final bool showDragScroll;
const ImmichAssetGrid({
super.key,
@ -47,6 +49,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
this.showMultiSelectIndicator = true,
this.visibleItemsListener,
this.topWidget,
this.shrinkWrap = false,
this.showDragScroll = true,
});
@override
@ -89,7 +93,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
perRow.value = 7 - scaleFactor.value.toInt();
}
};
})
}),
},
child: ImmichAssetGridView(
onRefresh: onRefresh,
@ -108,6 +112,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
visibleItemsListener: visibleItemsListener,
topWidget: topWidget,
heroOffset: heroOffset(),
shrinkWrap: shrinkWrap,
showDragScroll: showDragScroll,
),
);
}

View file

@ -35,6 +35,8 @@ class ImmichAssetGridView extends StatefulWidget {
visibleItemsListener;
final Widget? topWidget;
final int heroOffset;
final bool shrinkWrap;
final bool showDragScroll;
const ImmichAssetGridView({
super.key,
@ -52,6 +54,8 @@ class ImmichAssetGridView extends StatefulWidget {
this.visibleItemsListener,
this.topWidget,
this.heroOffset = 0,
this.shrinkWrap = false,
this.showDragScroll = true,
});
@override
@ -225,7 +229,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
right: i + 1 == num ? 0.0 : widget.margin,
),
color: Colors.grey,
)
),
],
);
}
@ -300,7 +304,13 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
}
Text _labelBuilder(int pos) {
final date = widget.renderList.elements[pos].date;
final maxLength = widget.renderList.elements.length;
if (pos < 0 || pos >= maxLength) {
return const Text("");
}
final date = widget.renderList.elements[pos % maxLength].date;
return Text(
DateFormat.yMMMM().format(date),
style: const TextStyle(
@ -318,7 +328,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
}
Widget _buildAssetGrid() {
final useDragScrolling = widget.renderList.totalAssets >= 20;
final useDragScrolling =
widget.showDragScroll && widget.renderList.totalAssets >= 20;
void dragScrolling(bool active) {
if (active != _scrolling) {
@ -335,8 +346,10 @@ 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,
shrinkWrap: widget.shrinkWrap,
);
final child = useDragScrolling

View file

@ -155,7 +155,7 @@ class ControlBottomAppBar extends ConsumerWidget {
if (hasRemote)
const SliverToBoxAdapter(
child: SizedBox(height: 200),
)
),
],
),
);

View file

@ -1,7 +1,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/user_circle_avatar.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/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';
@ -29,9 +30,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) {
if (authState.profileImagePath.isEmpty || user == null) {
return IconButton(
splashRadius: 25,
icon: const Icon(
@ -47,9 +48,10 @@ class HomePageAppBar extends ConsumerWidget implements PreferredSizeWidget {
onTap: () {
Scaffold.of(context).openDrawer();
},
child: const UserCircleAvatar(
child: UserCircleAvatar(
radius: 18,
size: 33,
user: user,
),
);
}

View file

@ -108,7 +108,7 @@ class ProfileDrawer extends HookConsumerWidget {
buildSignOutButton(),
],
),
const ServerInfoBox()
const ServerInfoBox(),
],
),
);

View file

@ -3,7 +3,8 @@ 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/modules/home/ui/user_circle_avatar.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/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';
@ -19,14 +20,10 @@ 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() {
var userImage = const UserCircleAvatar(
radius: 35,
size: 66,
);
if (authState.profileImagePath.isEmpty) {
if (authState.profileImagePath.isEmpty || user == null) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
@ -34,6 +31,12 @@ class ProfileDrawerHeader extends HookConsumerWidget {
);
}
var userImage = UserCircleAvatar(
radius: 35,
size: 66,
user: user,
);
if (uploadProfileImageStatus == UploadProfileStatus.idle) {
if (authState.profileImagePath.isNotEmpty) {
return userImage;
@ -153,7 +156,7 @@ class ProfileDrawerHeader extends HookConsumerWidget {
Text(
authState.userEmail,
style: Theme.of(context).textTheme.labelMedium,
)
),
],
),
);

View file

@ -1,44 +0,0 @@
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),
),
),
);
}
}

View file

@ -25,10 +25,9 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class HomePage extends HookConsumerWidget {
const HomePage({Key? key}) : super(key: key);
@ -88,17 +87,7 @@ class HomePage extends HookConsumerWidget {
}
void onShareAssets() {
showDialog(
context: context,
builder: (BuildContext buildContext) {
ref
.watch(shareServiceProvider)
.shareAssets(selection.value.toList())
.then((_) => Navigator.of(buildContext).pop());
return const ShareDialog();
},
barrierDismissible: false,
);
handleShareAssets(ref, context, selection.value.toList());
selectionEnabledHook.value = false;
}
@ -126,16 +115,7 @@ class HomePage extends HookConsumerWidget {
localErrorMessage: 'home_page_favorite_err_local'.tr(),
);
if (remoteAssets.isNotEmpty) {
await ref
.watch(assetProvider.notifier)
.toggleFavorite(remoteAssets, true);
final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg: 'Added ${remoteAssets.length} $assetOrAssets to favorites',
gravity: ToastGravity.BOTTOM,
);
await handleFavoriteAssets(ref, context, remoteAssets);
}
} finally {
processing.value = false;
@ -149,18 +129,7 @@ class HomePage extends HookConsumerWidget {
final remoteAssets = remoteOnlySelection(
localErrorMessage: 'home_page_archive_err_local'.tr(),
);
if (remoteAssets.isNotEmpty) {
await ref
.read(assetProvider.notifier)
.toggleArchive(remoteAssets, true);
final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
ImmichToast.show(
context: context,
msg: 'Moved ${remoteAssets.length} $assetOrAssets to archive',
gravity: ToastGravity.CENTER,
);
}
await handleArchiveAssets(ref, context, remoteAssets);
} finally {
processing.value = false;
selectionEnabledHook.value = false;
@ -221,7 +190,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 +292,7 @@ class HomePage extends HookConsumerWidget {
).tr(),
),
),
)
),
],
),
);
@ -365,7 +334,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()),
],
),
);

View file

@ -94,7 +94,7 @@ class ChangePasswordForm extends HookConsumerWidget {
),
],
),
)
),
],
),
),

View file

@ -0,0 +1,40 @@
import 'package:immich_mobile/shared/models/asset.dart';
enum MapPageEventType {
mapTap,
bottomSheetScrolled,
assetsInBoundUpdated,
zoomToAsset,
zoomToCurrentLocation,
}
class MapPageEventBase {
final MapPageEventType type;
const MapPageEventBase(this.type);
}
class MapPageOnTapEvent extends MapPageEventBase {
const MapPageOnTapEvent() : super(MapPageEventType.mapTap);
}
class MapPageAssetsInBoundUpdated extends MapPageEventBase {
List<Asset> assets;
MapPageAssetsInBoundUpdated(this.assets)
: super(MapPageEventType.assetsInBoundUpdated);
}
class MapPageBottomSheetScrolled extends MapPageEventBase {
Asset? asset;
MapPageBottomSheetScrolled(this.asset)
: super(MapPageEventType.bottomSheetScrolled);
}
class MapPageZoomToAsset extends MapPageEventBase {
Asset? asset;
MapPageZoomToAsset(this.asset) : super(MapPageEventType.zoomToAsset);
}
class MapPageZoomToLocation extends MapPageEventBase {
const MapPageZoomToLocation() : super(MapPageEventType.zoomToCurrentLocation);
}

View file

@ -0,0 +1,45 @@
class MapState {
final bool isDarkTheme;
final bool showFavoriteOnly;
final int relativeTime;
MapState({
this.isDarkTheme = false,
this.showFavoriteOnly = false,
this.relativeTime = 0,
});
MapState copyWith({
bool? isDarkTheme,
bool? showFavoriteOnly,
int? relativeTime,
}) {
return MapState(
isDarkTheme: isDarkTheme ?? this.isDarkTheme,
showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
relativeTime: relativeTime ?? this.relativeTime,
);
}
@override
String toString() {
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is MapState &&
other.isDarkTheme == isDarkTheme &&
other.showFavoriteOnly == showFavoriteOnly &&
other.relativeTime == relativeTime;
}
@override
int get hashCode {
return isDarkTheme.hashCode ^
showFavoriteOnly.hashCode ^
relativeTime.hashCode;
}
}

View file

@ -0,0 +1,58 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/modules/map/services/map.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:latlong2/latlong.dart';
final mapMarkersProvider =
FutureProvider.autoDispose<Set<AssetMarkerData>>((ref) async {
final service = ref.read(mapServiceProvider);
final mapState = ref.read(mapStateNotifier);
DateTime? fileCreatedAfter;
bool? isFavorite;
if (mapState.relativeTime != 0) {
fileCreatedAfter =
DateTime.now().subtract(Duration(days: mapState.relativeTime));
}
if (mapState.showFavoriteOnly) {
isFavorite = true;
}
final markers = await service.getMapMarkers(
isFavorite: isFavorite,
fileCreatedAfter: fileCreatedAfter,
);
final assetMarkerData = await Future.wait(
markers.map((e) async {
final asset = await service.getAssetForMarkerId(e.id);
bool hasInvalidCoords = e.lat < -90 || e.lat > 90;
hasInvalidCoords = hasInvalidCoords || (e.lon < -180 || e.lon > 180);
if (asset == null || hasInvalidCoords) return null;
return AssetMarkerData(asset, LatLng(e.lat, e.lon));
}),
);
return assetMarkerData.nonNulls.toSet();
});
class AssetMarkerData {
final LatLng point;
final Asset asset;
const AssetMarkerData(this.asset, this.point);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AssetMarkerData && other.asset.remoteId == asset.remoteId;
}
@override
int get hashCode {
return asset.remoteId.hashCode;
}
}

View file

@ -0,0 +1,51 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/models/map_state.model.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
class MapStateNotifier extends StateNotifier<MapState> {
MapStateNotifier(this.appSettingsProvider)
: super(
MapState(
isDarkTheme: appSettingsProvider
.getSetting<bool>(AppSettingsEnum.mapThemeMode),
showFavoriteOnly: appSettingsProvider
.getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly),
relativeTime: appSettingsProvider
.getSetting<int>(AppSettingsEnum.mapRelativeDate),
),
);
final AppSettingsService appSettingsProvider;
bool get isDarkTheme => state.isDarkTheme;
void switchTheme(bool isDarkTheme) {
appSettingsProvider.setSetting(
AppSettingsEnum.mapThemeMode,
isDarkTheme,
);
state = state.copyWith(isDarkTheme: isDarkTheme);
}
void switchFavoriteOnly(bool isFavoriteOnly) {
appSettingsProvider.setSetting(
AppSettingsEnum.mapShowFavoriteOnly,
appSettingsProvider,
);
state = state.copyWith(showFavoriteOnly: isFavoriteOnly);
}
void setRelativeTime(int relativeTime) {
appSettingsProvider.setSetting(
AppSettingsEnum.mapRelativeDate,
relativeTime,
);
state = state.copyWith(relativeTime: relativeTime);
}
}
final mapStateNotifier =
StateNotifierProvider<MapStateNotifier, MapState>((ref) {
return MapStateNotifier(ref.watch(appSettingsServiceProvider));
});

View file

@ -0,0 +1,62 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final mapServiceProvider = Provider(
(ref) => MapSerivce(
ref.read(apiServiceProvider),
ref.read(dbProvider),
),
);
class MapSerivce {
final ApiService _apiService;
final Isar _db;
final log = Logger("MapService");
MapSerivce(this._apiService, this._db);
Future<List<MapMarkerResponseDto>> getMapMarkers({
bool? isFavorite,
DateTime? fileCreatedAfter,
DateTime? fileCreatedBefore,
}) async {
try {
final markers = await _apiService.assetApi.getMapMarkers(
isFavorite: isFavorite,
fileCreatedAfter: fileCreatedAfter,
fileCreatedBefore: fileCreatedBefore,
);
return markers ?? [];
} catch (error, stack) {
log.severe("Cannot get map markers ${error.toString()}", error, stack);
return [];
}
}
Future<Asset?> getAssetForMarkerId(String remoteId) async {
try {
final assets = await _db.assets.getAllByRemoteId([remoteId]);
if (assets.isNotEmpty) return assets[0];
final dto = await _apiService.assetApi.getAssetById(remoteId);
if (dto == null) {
return null;
}
return Asset.remote(dto);
} catch (error, stack) {
log.severe(
"Cannot get asset for marker ${error.toString()}",
error,
stack,
);
return null;
}
}
}

View file

@ -0,0 +1,144 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class AssetMarkerIcon extends StatelessWidget {
const AssetMarkerIcon({
Key? key,
required this.id,
this.isDarkTheme = false,
}) : super(key: key);
final String id;
final bool isDarkTheme;
@override
Widget build(BuildContext context) {
final imageUrl = getThumbnailUrlForRemoteId(id);
final cacheKey = getThumbnailCacheKeyForRemoteId(id);
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
Positioned(
bottom: 0,
left: constraints.maxWidth * 0.5,
child: CustomPaint(
painter: _PinPainter(
primaryColor: isDarkTheme ? Colors.white : Colors.black,
secondaryColor: isDarkTheme ? Colors.black : Colors.white,
primaryRadius: constraints.maxHeight * 0.06,
secondaryRadius: constraints.maxHeight * 0.038,
),
child: SizedBox(
height: constraints.maxHeight * 0.14,
width: constraints.maxWidth * 0.14,
),
),
),
Positioned(
top: constraints.maxHeight * 0.07,
left: constraints.maxWidth * 0.17,
child: CircleAvatar(
radius: constraints.maxHeight * 0.40,
backgroundColor: isDarkTheme ? Colors.white : Colors.black,
child: CircleAvatar(
radius: constraints.maxHeight * 0.37,
backgroundImage: CachedNetworkImageProvider(
imageUrl,
cacheKey: cacheKey,
headers: {
"Authorization":
"Bearer ${Store.get(StoreKey.accessToken)}",
},
errorListener: () =>
const Icon(Icons.image_not_supported_outlined),
),
),
),
),
],
);
},
);
}
}
class _PinPainter extends CustomPainter {
final Color primaryColor;
final Color secondaryColor;
final double primaryRadius;
final double secondaryRadius;
_PinPainter({
this.primaryColor = Colors.black,
this.secondaryColor = Colors.white,
required this.primaryRadius,
required this.secondaryRadius,
});
@override
void paint(Canvas canvas, Size size) {
Paint primaryBrush = Paint()
..color = primaryColor
..style = PaintingStyle.fill;
Paint secondaryBrush = Paint()
..color = secondaryColor
..style = PaintingStyle.fill;
Paint lineBrush = Paint()
..color = primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(
Offset(size.width / 2, size.height),
primaryRadius,
primaryBrush,
);
canvas.drawCircle(
Offset(size.width / 2, size.height),
secondaryRadius,
secondaryBrush,
);
canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
// The line is to make the above triangluar path more prominent since it has a slight curve
canvas.drawLine(
Offset(size.width / 2, 0),
Offset(
size.width / 2,
size.height,
),
lineBrush,
);
}
Path getTrianglePath(double x, double y) {
final firstEndPoint = Offset(x / 2, y);
final controlPoint = Offset(x / 2, y * 0.3);
final secondEndPoint = Offset(x, 0);
return Path()
..quadraticBezierTo(
controlPoint.dx,
controlPoint.dy,
firstEndPoint.dx,
firstEndPoint.dy,
)
..quadraticBezierTo(
controlPoint.dx,
controlPoint.dy,
secondEndPoint.dx,
secondEndPoint.dy,
)
..lineTo(0, 0);
}
@override
bool shouldRepaint(_PinPainter old) {
return old.primaryColor != primaryColor ||
old.secondaryColor != secondaryColor;
}
}

View file

@ -0,0 +1,30 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
class LocationServiceDisabledDialog extends ConfirmDialog {
LocationServiceDisabledDialog({Key? key})
: super(
key: key,
title: 'map_location_service_disabled_title'.tr(),
content: 'map_location_service_disabled_content'.tr(),
cancel: 'map_location_dialog_cancel'.tr(),
ok: 'map_location_dialog_yes'.tr(),
onOk: () async {
await Geolocator.openLocationSettings();
},
);
}
class LocationPermissionDisabledDialog extends ConfirmDialog {
LocationPermissionDisabledDialog({Key? key})
: super(
key: key,
title: 'map_no_location_permission_title'.tr(),
content: 'map_no_location_permission_content'.tr(),
cancel: 'map_location_dialog_cancel'.tr(),
ok: 'map_location_dialog_yes'.tr(),
onOk: () {},
);
}

View file

@ -0,0 +1,138 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/disable_multi_select_button.dart';
import 'package:immich_mobile/modules/map/ui/map_settings_dialog.dart';
class MapAppBar extends HookWidget implements PreferredSizeWidget {
final ValueNotifier<bool> selectionEnabled;
final int selectedAssetsLength;
final bool isDarkTheme;
final void Function() onShare;
final void Function() onFavorite;
final void Function() onArchive;
const MapAppBar({
super.key,
required this.selectionEnabled,
required this.selectedAssetsLength,
required this.onShare,
required this.onArchive,
required this.onFavorite,
this.isDarkTheme = false,
});
List<Widget> buildNonSelectionWidgets(BuildContext context) {
return [
Padding(
padding: const EdgeInsets.only(left: 15, top: 15),
child: ElevatedButton(
onPressed: () => AutoRouter.of(context).pop(),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(Icons.arrow_back_ios_new_rounded, size: 22),
),
),
Padding(
padding: const EdgeInsets.only(right: 15, top: 15),
child: ElevatedButton(
onPressed: () => showDialog(
context: context,
builder: (BuildContext _) {
return const MapSettingsDialog();
},
),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(Icons.more_vert_rounded, size: 22),
),
),
];
}
List<Widget> buildSelectionWidgets() {
return [
DisableMultiSelectButton(
onPressed: () {
selectionEnabled.value = false;
},
selectedItemCount: selectedAssetsLength,
),
Row(
children: [
// Share button
Padding(
padding: const EdgeInsets.only(top: 15),
child: ElevatedButton(
onPressed: onShare,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: Icon(
Platform.isAndroid
? Icons.share_rounded
: Icons.ios_share_rounded,
size: 22,
),
),
),
// Favorite button
Padding(
padding: const EdgeInsets.only(top: 15),
child: ElevatedButton(
onPressed: onFavorite,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(
Icons.favorite,
size: 22,
),
),
),
// Archive Button
Padding(
padding: const EdgeInsets.only(right: 10, top: 15),
child: ElevatedButton(
onPressed: onArchive,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(
Icons.archive,
size: 22,
),
),
),
],
),
];
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 30),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (!selectionEnabled.value) ...buildNonSelectionWidgets(context),
if (selectionEnabled.value) ...buildSelectionWidgets(),
],
),
);
}
@override
Size get preferredSize => const Size.fromHeight(100);
}

View file

@ -0,0 +1,356 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:url_launcher/url_launcher.dart';
class MapPageBottomSheet extends StatefulHookConsumerWidget {
final Stream mapPageEventStream;
final StreamController bottomSheetEventSC;
final bool selectionEnabled;
final ImmichAssetGridSelectionListener selectionlistener;
final bool isDarkTheme;
const MapPageBottomSheet({
super.key,
required this.mapPageEventStream,
required this.bottomSheetEventSC,
required this.selectionEnabled,
required this.selectionlistener,
this.isDarkTheme = false,
});
@override
AssetsInBoundBottomSheetState createState() =>
AssetsInBoundBottomSheetState();
}
class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> {
// Non-State variables
bool userTappedOnMap = false;
RenderList? _cachedRenderList;
int lastAssetOffsetInSheet = -1;
late final DraggableScrollableController bottomSheetController;
late final Debounce debounce;
@override
void initState() {
super.initState();
bottomSheetController = DraggableScrollableController();
debounce = Debounce(
const Duration(milliseconds: 200),
);
}
@override
Widget build(BuildContext context) {
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
double maxHeight = MediaQuery.of(context).size.height;
final isSheetScrolled = useState(false);
final isSheetExpanded = useState(false);
final assetsInBound = useState(<Asset>[]);
final currentExtend = useState(0.1);
void handleMapPageEvents(dynamic event) {
if (event is MapPageAssetsInBoundUpdated) {
assetsInBound.value = event.assets;
} else if (event is MapPageOnTapEvent) {
userTappedOnMap = true;
lastAssetOffsetInSheet = -1;
bottomSheetController.animateTo(
0.1,
duration: const Duration(milliseconds: 200),
curve: Curves.linearToEaseOut,
);
isSheetScrolled.value = false;
}
}
useEffect(
() {
final mapPageEventSubscription =
widget.mapPageEventStream.listen(handleMapPageEvents);
return mapPageEventSubscription.cancel;
},
[widget.mapPageEventStream],
);
void handleVisibleItems(ItemPosition start, ItemPosition end) {
final renderElement = _cachedRenderList?.elements[start.index];
if (renderElement == null) {
return;
}
final rowOffset = renderElement.offset;
if ((-start.itemLeadingEdge) != 0) {
var columnOffset = -start.itemLeadingEdge ~/ 0.05;
columnOffset = columnOffset < renderElement.totalCount
? columnOffset
: renderElement.totalCount - 1;
lastAssetOffsetInSheet = rowOffset + columnOffset;
final asset = _cachedRenderList?.allAssets?[lastAssetOffsetInSheet];
userTappedOnMap = false;
if (!userTappedOnMap && isSheetExpanded.value) {
widget.bottomSheetEventSC.add(
MapPageBottomSheetScrolled(asset),
);
}
if (isSheetExpanded.value) {
isSheetScrolled.value = true;
}
}
}
void visibleItemsListener(ItemPosition start, ItemPosition end) {
if (_cachedRenderList == null) {
debounce.dispose();
return;
}
debounce.call(() => handleVisibleItems(start, end));
}
Widget buildNoPhotosWidget() {
const image = Image(
image: AssetImage('assets/lighthouse.png'),
);
return isSheetExpanded.value
? Column(
children: [
const SizedBox(
height: 80,
),
SizedBox(
height: 150,
width: 150,
child: isDarkMode
? const InvertionFilter(
child: SaturationFilter(
saturation: -1,
child: BrightnessFilter(
brightness: -5,
child: image,
),
),
)
: image,
),
const SizedBox(
height: 20,
),
Text(
"map_zoom_to_see_photos".tr(),
style: TextStyle(
fontSize: 20,
color: Theme.of(context).textTheme.displayLarge?.color,
),
),
],
)
: const SizedBox.shrink();
}
void onTapMapButton() {
if (lastAssetOffsetInSheet != -1) {
widget.bottomSheetEventSC.add(
MapPageZoomToAsset(
_cachedRenderList?.allAssets?[lastAssetOffsetInSheet],
),
);
}
}
Widget buildDragHandle(ScrollController scrollController) {
final textToDisplay = assetsInBound.value.isNotEmpty
? "${assetsInBound.value.length} photo${assetsInBound.value.length > 1 ? "s" : ""}"
: "map_no_assets_in_bounds".tr();
final dragHandle = Container(
height: 75,
width: double.infinity,
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 12),
const CustomDraggingHandle(),
const SizedBox(height: 12),
Text(
textToDisplay,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).textTheme.displayLarge?.color,
fontWeight: FontWeight.bold,
),
),
Divider(
color: Theme.of(context)
.textTheme
.displayLarge
?.color
?.withOpacity(0.5),
),
],
),
if (isSheetExpanded.value && isSheetScrolled.value)
Positioned(
top: 5,
right: 10,
child: IconButton(
icon: Icon(
Icons.map_outlined,
color: Theme.of(context).textTheme.displayLarge?.color,
),
iconSize: 20,
tooltip: 'Zoom to bounds',
onPressed: onTapMapButton,
),
),
],
),
);
return SingleChildScrollView(
controller: scrollController,
child: dragHandle,
);
}
return NotificationListener<DraggableScrollableNotification>(
onNotification: (DraggableScrollableNotification notification) {
final sheetExtended = notification.extent > 0.2;
isSheetExpanded.value = sheetExtended;
currentExtend.value = notification.extent;
if (!sheetExtended) {
// reset state
userTappedOnMap = false;
lastAssetOffsetInSheet = -1;
isSheetScrolled.value = false;
}
return true;
},
child: Stack(
children: [
DraggableScrollableSheet(
controller: bottomSheetController,
initialChildSize: 0.1,
minChildSize: 0.1,
maxChildSize: 0.55,
snap: true,
builder: (
BuildContext context,
ScrollController scrollController,
) {
return Card(
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
surfaceTintColor: Colors.transparent,
elevation: 18.0,
margin: const EdgeInsets.all(0),
child: Column(
children: [
buildDragHandle(scrollController),
if (isSheetExpanded.value && assetsInBound.value.isNotEmpty)
ref
.watch(
renderListProvider(
assetsInBound.value,
),
)
.when(
data: (renderList) {
_cachedRenderList = renderList;
final assetGrid = ImmichAssetGrid(
shrinkWrap: true,
renderList: renderList,
showDragScroll: false,
selectionActive: widget.selectionEnabled,
showMultiSelectIndicator: false,
listener: widget.selectionlistener,
visibleItemsListener: visibleItemsListener,
);
return Expanded(child: assetGrid);
},
error: (error, stackTrace) {
log.warning(
"Cannot get assets in the current map bounds ${error.toString()}",
error,
stackTrace,
);
return const SizedBox.shrink();
},
loading: () => const SizedBox.shrink(),
),
if (isSheetExpanded.value && assetsInBound.value.isEmpty)
Expanded(
child: SingleChildScrollView(
child: buildNoPhotosWidget(),
),
),
],
),
);
},
),
Positioned(
bottom: maxHeight * currentExtend.value,
left: 0,
child: GestureDetector(
onTap: () => launchUrl(
Uri.parse('https://openstreetmap.org/copyright'),
),
child: ColoredBox(
color:
(widget.isDarkTheme ? Colors.grey[900] : Colors.grey[100])!,
child: Padding(
padding: const EdgeInsets.all(3),
child: Text(
'© OpenStreetMap contributors',
style: TextStyle(
fontSize: 6,
color: !widget.isDarkTheme
? Colors.grey[900]
: Colors.grey[100],
),
),
),
),
),
),
Positioned(
bottom: maxHeight * (0.14 + (currentExtend.value - 0.1)),
right: 15,
child: ElevatedButton(
onPressed: () =>
widget.bottomSheetEventSC.add(const MapPageZoomToLocation()),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(
Icons.my_location,
size: 22,
fill: 1,
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,193 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
class MapSettingsDialog extends HookConsumerWidget {
const MapSettingsDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapSettingsNotifier = ref.read(mapStateNotifier.notifier);
final mapSettings = ref.read(mapStateNotifier);
final isDarkMode = useState(mapSettings.isDarkTheme);
final showFavoriteOnly = useState(mapSettings.showFavoriteOnly);
final showRelativeDate = useState(mapSettings.relativeTime);
final ThemeData theme = Theme.of(context);
Widget buildMapThemeSetting() {
return SwitchListTile.adaptive(
value: isDarkMode.value,
onChanged: (value) {
isDarkMode.value = value;
},
activeColor: theme.primaryColor,
dense: true,
title: Text(
"map_settings_dark_mode".tr(),
style:
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
),
);
}
Widget buildFavoriteOnlySetting() {
return SwitchListTile.adaptive(
value: showFavoriteOnly.value,
onChanged: (value) {
showFavoriteOnly.value = value;
},
activeColor: theme.primaryColor,
dense: true,
title: Text(
"map_settings_only_show_favorites".tr(),
style:
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
),
);
}
Widget buildDateRangeSetting() {
final now = DateTime.now();
return DropdownMenu(
enableSearch: false,
enableFilter: false,
initialSelection: showRelativeDate.value,
onSelected: (value) {
showRelativeDate.value = value!;
},
dropdownMenuEntries: [
const DropdownMenuEntry(value: 0, label: "All"),
const DropdownMenuEntry(
value: 1,
label: "Past 24 hours",
),
const DropdownMenuEntry(
value: 7,
label: "Past 7 days",
),
const DropdownMenuEntry(
value: 30,
label: "Past 30 days",
),
DropdownMenuEntry(
value: now
.difference(
DateTime(
now.year - 1,
now.month,
now.day,
now.hour,
now.minute,
now.second,
),
)
.inDays,
label: "Past year",
),
DropdownMenuEntry(
value: now
.difference(
DateTime(
now.year - 3,
now.month,
now.day,
now.hour,
now.minute,
now.second,
),
)
.inDays,
label: "Past 3 years",
),
],
);
}
List<Widget> getDialogActions() {
return <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
backgroundColor:
mapSettings.isDarkTheme ? Colors.grey[100] : Colors.grey[700],
),
child: Text(
"map_settings_dialog_cancel".tr(),
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
color:
mapSettings.isDarkTheme ? Colors.grey[900] : Colors.grey[100],
),
),
),
TextButton(
onPressed: () {
mapSettingsNotifier.switchTheme(isDarkMode.value);
mapSettingsNotifier.switchFavoriteOnly(showFavoriteOnly.value);
mapSettingsNotifier.setRelativeTime(showRelativeDate.value);
Navigator.of(context).pop();
},
style: TextButton.styleFrom(
backgroundColor: theme.primaryColor,
),
child: Text(
"map_settings_dialog_save".tr(),
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.primaryTextTheme.labelLarge?.color,
),
),
),
];
}
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: Center(
child: Text(
"map_settings_dialog_title".tr(),
style: TextStyle(
color: theme.primaryColor,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
),
child: ListView(
shrinkWrap: true,
children: [
buildMapThemeSetting(),
buildFavoriteOnlySetting(),
const SizedBox(
height: 10,
),
Padding(
padding: const EdgeInsets.only(left: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"map_settings_only_relative_range".tr(),
style: const TextStyle(fontWeight: FontWeight.bold),
),
buildDateRangeSetting(),
],
),
),
].toList(),
),
),
),
actions: getDialogActions(),
actionsAlignment: MainAxisAlignment.spaceEvenly,
);
}
}

View file

@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/plugin_api.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
import 'package:latlong2/latlong.dart';
import 'package:url_launcher/url_launcher.dart';
// A non-interactive thumbnail of a map in the given coordinates with optional markers
class MapThumbnail extends HookConsumerWidget {
final Function(TapPosition, LatLng)? onTap;
final LatLng coords;
final double zoom;
final List<Marker> markers;
final double height;
final bool showAttribution;
final bool isDarkTheme;
const MapThumbnail({
super.key,
required this.coords,
required this.height,
this.onTap,
this.zoom = 1,
this.showAttribution = true,
this.isDarkTheme = false,
this.markers = const [],
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tileLayer = TileLayer(
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: const ['a', 'b', 'c'],
);
return SizedBox(
height: height,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: FlutterMap(
options: MapOptions(
interactiveFlags: InteractiveFlag.none,
center: coords,
zoom: zoom,
onTap: onTap,
),
nonRotatedChildren: [
if (showAttribution)
RichAttributionWidget(
animationConfig: const ScaleRAWA(),
attributions: [
TextSourceAttribution(
'OpenStreetMap contributors',
onTap: () => launchUrl(
Uri.parse('https://openstreetmap.org/copyright'),
),
),
],
),
],
children: [
isDarkTheme
? InvertionFilter(
child: SaturationFilter(
saturation: -1,
child: tileLayer,
),
)
: tileLayer,
if (markers.isNotEmpty) MarkerLayer(markers: markers),
],
),
),
);
}
}

View file

@ -0,0 +1,499 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_map/plugin_api.dart';
import 'package:flutter_map_heatmap/flutter_map_heatmap.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
import 'package:immich_mobile/modules/map/providers/map_marker.provider.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/modules/map/ui/asset_marker_icon.dart';
import 'package:immich_mobile/modules/map/ui/location_dialog.dart';
import 'package:immich_mobile/modules/map/ui/map_page_bottom_sheet.dart';
import 'package:immich_mobile/modules/map/ui/map_page_app_bar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/flutter_map_extensions.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:latlong2/latlong.dart';
import 'package:logging/logging.dart';
class MapPage extends StatefulHookConsumerWidget {
const MapPage({super.key});
@override
MapPageState createState() => MapPageState();
}
class MapPageState extends ConsumerState<MapPage> {
// Non-State variables
late final MapController mapController;
// Streams are used instead of callbacks to prevent unnecessary rebuilds on events
final StreamController mapPageEventSC =
StreamController<MapPageEventBase>.broadcast();
final StreamController bottomSheetEventSC =
StreamController<MapPageEventBase>.broadcast();
late final Stream bottomSheetEventStream;
// Making assets in bounds as a state variable will result in un-necessary rebuilds of the bottom sheet
// resulting in it getting reloaded each time a map move occurs
Set<AssetMarkerData> assetsInBounds = {};
// TODO: Migrate the handling to MapEventMove#id when flutter_map is upgraded
// https://github.com/fleaflet/flutter_map/issues/1542
// The below is used instead of MapEventMove#id to handle event from controller
// in onMapEvent() since MapEventMove#id is not populated properly in the
// current version of flutter_map(4.0.0) used
bool forceAssetUpdate = false;
late final Debounce debounce;
@override
void initState() {
super.initState();
mapController = MapController();
bottomSheetEventStream = bottomSheetEventSC.stream;
// Map zoom events and move events are triggered often. Throttle the call to limit rebuilds
debounce = Debounce(
const Duration(milliseconds: 300),
);
}
@override
void dispose() {
debounce.dispose();
super.dispose();
}
void reloadAssetsInBound(
Set<AssetMarkerData>? assetMarkers, {
bool forceReload = false,
}) {
final bounds = mapController.bounds;
if (bounds != null) {
final oldAssetsInBounds = assetsInBounds.toSet();
assetsInBounds =
assetMarkers?.where((e) => bounds.contains(e.point)).toSet() ?? {};
final shouldReload = forceReload ||
assetsInBounds.difference(oldAssetsInBounds).isNotEmpty ||
assetsInBounds.length != oldAssetsInBounds.length;
if (shouldReload) {
mapPageEventSC.add(
MapPageAssetsInBoundUpdated(
assetsInBounds.map((e) => e.asset).toList(),
),
);
}
}
}
void openAssetInViewer(Asset asset) {
AutoRouter.of(context).push(
GalleryViewerRoute(
initialIndex: 0,
loadAsset: (index) => asset,
totalAssets: 1,
heroOffset: 0,
),
);
}
@override
Widget build(BuildContext context) {
final log = Logger("MapService");
final isDarkTheme =
ref.watch(mapStateNotifier.select((state) => state.isDarkTheme));
final ValueNotifier<Set<AssetMarkerData>> mapMarkerData =
useState(<AssetMarkerData>{});
final ValueNotifier<AssetMarkerData?> closestAssetMarker = useState(null);
final selectionEnabledHook = useState(false);
final selectedAssets = useState(<Asset>{});
final showLoadingIndicator = useState(false);
final refetchMarkers = useState(true);
if (refetchMarkers.value) {
mapMarkerData.value = ref.watch(mapMarkersProvider).when(
skipLoadingOnRefresh: false,
error: (error, stackTrace) {
log.warning(
"Cannot get map markers ${error.toString()}",
error,
stackTrace,
);
showLoadingIndicator.value = false;
return {};
},
loading: () {
showLoadingIndicator.value = true;
return {};
},
data: (data) {
showLoadingIndicator.value = false;
refetchMarkers.value = false;
closestAssetMarker.value = null;
debounce(
() => reloadAssetsInBound(
mapMarkerData.value,
forceReload: true,
),
);
return data;
},
);
}
ref.listen(mapStateNotifier, (previous, next) {
bool shouldRefetch =
previous?.showFavoriteOnly != next.showFavoriteOnly ||
previous?.relativeTime != next.relativeTime;
if (shouldRefetch) {
refetchMarkers.value = shouldRefetch;
ref.invalidate(mapMarkersProvider);
}
});
void onZoomToAssetEvent(Asset? assetInBottomSheet) {
if (assetInBottomSheet != null) {
final mapMarker = mapMarkerData.value
.firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
if (mapMarker != null) {
LatLng? newCenter = mapController.centerBoundsWithPadding(
mapMarker.point,
const Offset(0, -120),
zoomLevel: 6,
);
if (newCenter != null) {
forceAssetUpdate = true;
mapController.move(newCenter, 6);
}
}
}
}
void onZoomToLocation() async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
showDialog(
context: context,
builder: (context) => Theme(
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
child: LocationServiceDisabledDialog(),
),
);
return;
}
LocationPermission permission = await Geolocator.checkPermission();
bool shouldRequestPermission = false;
if (permission == LocationPermission.denied) {
shouldRequestPermission = await showDialog(
context: context,
builder: (context) => Theme(
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
child: LocationPermissionDisabledDialog(),
),
);
if (shouldRequestPermission) {
permission = await Geolocator.requestPermission();
}
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
// Open app settings only if you did not request for permission before
if (permission == LocationPermission.deniedForever &&
!shouldRequestPermission) {
await Geolocator.openAppSettings();
}
return;
}
Position currentUserLocation = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.medium,
timeLimit: const Duration(seconds: 5),
);
forceAssetUpdate = true;
mapController.move(
LatLng(currentUserLocation.latitude, currentUserLocation.longitude),
12,
);
} catch (error) {
log.severe(
"Cannot get user's current location due to ${error.toString()}",
);
if (context.mounted) {
ImmichToast.show(
context: context,
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
msg: "map_cannot_get_user_location".tr(),
);
}
}
}
void handleBottomSheetEvents(dynamic event) {
if (event is MapPageBottomSheetScrolled) {
final assetInBottomSheet = event.asset;
if (assetInBottomSheet != null) {
final mapMarker = mapMarkerData.value
.firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
closestAssetMarker.value = mapMarker;
if (mapMarker != null && mapController.zoom >= 5) {
LatLng? newCenter = mapController.centerBoundsWithPadding(
mapMarker.point,
const Offset(0, -120),
);
if (newCenter != null) {
mapController.move(
newCenter,
mapController.zoom,
);
}
}
}
} else if (event is MapPageZoomToAsset) {
onZoomToAssetEvent(event.asset);
} else if (event is MapPageZoomToLocation) {
onZoomToLocation();
}
}
useEffect(
() {
final bottomSheetEventSubscription =
bottomSheetEventStream.listen(handleBottomSheetEvents);
return bottomSheetEventSubscription.cancel;
},
[bottomSheetEventStream],
);
void handleMapTapEvent(LatLng tapPosition) {
const d = Distance();
final assetsInBoundsList = assetsInBounds.toList();
assetsInBoundsList.sort(
(a, b) => d
.distance(a.point, tapPosition)
.compareTo(d.distance(b.point, tapPosition)),
);
// First asset less than the threshold from the tap point
final nearestAsset = assetsInBoundsList.firstWhereOrNull(
(element) =>
d.distance(element.point, tapPosition) <
mapController.getTapThresholdForZoomLevel(),
);
// Reset marker if no assets are near the tap point
if (nearestAsset == null && closestAssetMarker.value != null) {
selectionEnabledHook.value = false;
mapPageEventSC.add(
const MapPageOnTapEvent(),
);
}
closestAssetMarker.value = nearestAsset;
}
void onMapEvent(MapEvent mapEvent) {
if (mapEvent is MapEventMove || mapEvent is MapEventDoubleTapZoom) {
if (forceAssetUpdate ||
mapEvent.source != MapEventSource.mapController) {
debounce(() {
if (selectionEnabledHook.value) {
selectionEnabledHook.value = false;
}
reloadAssetsInBound(
mapMarkerData.value,
forceReload: forceAssetUpdate,
);
forceAssetUpdate = false;
});
}
} else if (mapEvent is MapEventTap) {
handleMapTapEvent(mapEvent.tapPosition);
}
}
void onShareAsset() {
handleShareAssets(ref, context, selectedAssets.value.toList());
selectionEnabledHook.value = false;
}
void onFavoriteAsset() async {
showLoadingIndicator.value = true;
try {
await handleFavoriteAssets(ref, context, selectedAssets.value.toList());
} finally {
showLoadingIndicator.value = false;
selectionEnabledHook.value = false;
refetchMarkers.value = true;
}
}
void onArchiveAsset() async {
showLoadingIndicator.value = true;
try {
await handleArchiveAssets(ref, context, selectedAssets.value.toList());
} finally {
showLoadingIndicator.value = false;
selectionEnabledHook.value = false;
refetchMarkers.value = true;
}
}
void selectionListener(bool isMultiSelect, Set<Asset> selection) {
selectionEnabledHook.value = isMultiSelect;
selectedAssets.value = selection;
}
final tileLayer = TileLayer(
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: const ['a', 'b', 'c'],
maxNativeZoom: 19,
maxZoom: 19,
);
final darkTileLayer = InvertionFilter(
child: SaturationFilter(
saturation: -1,
child: BrightnessFilter(
brightness: -1,
child: tileLayer,
),
),
);
final markerLayer = MarkerLayer(
markers: [
if (closestAssetMarker.value != null)
AssetMarker(
remoteId: closestAssetMarker.value!.asset.remoteId!,
anchorPos: AnchorPos.align(AnchorAlign.top),
point: closestAssetMarker.value!.point,
width: 100,
height: 100,
builder: (ctx) => GestureDetector(
onTap: () => openAssetInViewer(closestAssetMarker.value!.asset),
child: AssetMarkerIcon(
isDarkTheme: isDarkTheme,
id: closestAssetMarker.value!.asset.remoteId!,
),
),
),
],
);
final heatMapLayer = mapMarkerData.value.isNotEmpty
? HeatMapLayer(
heatMapDataSource: InMemoryHeatMapDataSource(
data: mapMarkerData.value
.map(
(e) => WeightedLatLng(
LatLng(e.point.latitude, e.point.longitude),
1,
),
)
.toList(),
),
heatMapOptions: HeatMapOptions(
radius: 60,
layerOpacity: 0.5,
gradient: {
0.20: Colors.deepPurple,
0.40: Colors.blue,
0.60: Colors.green,
0.95: Colors.yellow,
1.0: Colors.deepOrange,
},
),
)
: const SizedBox.shrink();
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
statusBarColor: Colors.black.withOpacity(0.5),
statusBarIconBrightness: Brightness.light,
),
child: Theme(
// Override app theme based on map theme
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
child: Scaffold(
appBar: MapAppBar(
isDarkTheme: isDarkTheme,
selectionEnabled: selectionEnabledHook,
selectedAssetsLength: selectedAssets.value.length,
onShare: onShareAsset,
onArchive: onArchiveAsset,
onFavorite: onFavoriteAsset,
),
extendBodyBehindAppBar: true,
body: Stack(
children: [
FlutterMap(
mapController: mapController,
options: MapOptions(
maxBounds:
LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
interactiveFlags: InteractiveFlag.doubleTapZoom |
InteractiveFlag.drag |
InteractiveFlag.flingAnimation |
InteractiveFlag.pinchMove |
InteractiveFlag.pinchZoom,
center: LatLng(20, 20),
zoom: 2,
minZoom: 1,
maxZoom: 18, // max level supported by OSM,
onMapReady: () {
mapController.mapEventStream.listen(onMapEvent);
},
),
children: [
isDarkTheme ? darkTileLayer : tileLayer,
heatMapLayer,
markerLayer,
],
),
MapPageBottomSheet(
mapPageEventStream: mapPageEventSC.stream,
bottomSheetEventSC: bottomSheetEventSC,
selectionEnabled: selectionEnabledHook.value,
selectionlistener: selectionListener,
isDarkTheme: isDarkTheme,
),
if (showLoadingIndicator.value)
Positioned(
top: MediaQuery.of(context).size.height * 0.35,
left: MediaQuery.of(context).size.width * 0.425,
child: const ImmichLoadingIndicator(),
),
],
),
),
),
);
}
}
class AssetMarker extends Marker {
String remoteId;
AssetMarker({
super.key,
required this.remoteId,
super.anchorPos,
required super.point,
super.width = 100.0,
super.height = 100.0,
required super.builder,
});
}

View file

@ -110,7 +110,7 @@ class MemoryCard extends HookConsumerWidget {
left: 18.0,
bottom: 18.0,
child: buildTitle(),
)
),
],
),
);

View file

@ -153,6 +153,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
child = buildRequestPermission();
break;
case PermissionStatus.granted:
case PermissionStatus.provisional:
child = buildPermissionGranted();
break;
case PermissionStatus.restricted:
@ -183,7 +184,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(),

View file

@ -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),

View file

@ -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,
),
),
)
),
],
),
),

View file

@ -0,0 +1,110 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
import 'package:immich_mobile/modules/search/ui/curated_row.dart';
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:latlong2/latlong.dart';
class CuratedPlacesRow extends CuratedRow {
const CuratedPlacesRow({
super.key,
required super.content,
super.imageSize,
super.onTap,
});
@override
Widget build(BuildContext context) {
Widget buildMapThumbnail() {
return GestureDetector(
onTap: () => AutoRouter.of(context).push(
const MapRoute(),
),
child: SizedBox(
height: imageSize,
width: imageSize,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(right: 10.0),
child: MapThumbnail(
zoom: 2,
coords: LatLng(
47,
5,
),
height: imageSize,
showAttribution: false,
isDarkTheme: Theme.of(context).brightness == Brightness.dark,
),
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Colors.black,
gradient: LinearGradient(
begin: FractionalOffset.topCenter,
end: FractionalOffset.bottomCenter,
colors: [
Colors.blueGrey.withOpacity(0.0),
Colors.black.withOpacity(0.4),
],
stops: const [0.0, 1.0],
),
),
),
const Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(bottom: 10),
child: Text(
"Your Map",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
),
],
),
),
);
}
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
itemBuilder: (context, index) {
// Injecting Map thumbnail as the first element
if (index == 0) {
return buildMapThumbnail();
}
// The actual index is 1 less than the virutal index since we inject map into the first position
final actualIndex = index - 1;
final object = content[actualIndex];
final thumbnailRequestUrl =
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}';
return SizedBox(
width: imageSize,
height: imageSize,
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: object.label,
onTap: () => onTap?.call(object, actualIndex),
),
),
);
},
// Adding 1 to inject map thumbnail as first element
itemCount: content.length + 1,
);
}
}

View file

@ -39,7 +39,7 @@ class SearchSuggestionList extends ConsumerWidget {
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
)
),
],
),
),

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