merge main
4
.github/workflows/docker-cleanup.yml
vendored
|
@ -38,7 +38,7 @@ jobs:
|
|||
-
|
||||
name: Clean temporary images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.3.0
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.4.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "immich-app"
|
||||
|
@ -70,7 +70,7 @@ jobs:
|
|||
-
|
||||
name: Clean untagged images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.3.0
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.4.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "immich-app"
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<br/>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
|
||||
<a href="https://discord.gg/D8JsnBEuKb">
|
||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
|
||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
|
||||
</a>
|
||||
<br/>
|
||||
<br/>
|
||||
|
|
2
cli/src/api/open-api/base.ts
generated
|
@ -4,7 +4,7 @@
|
|||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.84.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
2
cli/src/api/open-api/common.ts
generated
|
@ -4,7 +4,7 @@
|
|||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.84.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
2
cli/src/api/open-api/configuration.ts
generated
|
@ -4,7 +4,7 @@
|
|||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.84.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
2
cli/src/api/open-api/index.ts
generated
|
@ -4,7 +4,7 @@
|
|||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.84.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
# See:
|
||||
# - https://immich.app/docs/developer/setup
|
||||
# - https://immich.app/docs/developer/troubleshooting
|
||||
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
|
@ -71,10 +75,6 @@ services:
|
|||
command: npm run dev --host
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Rename these values for svelte public interface
|
||||
- PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL}
|
||||
- PUBLIC_IMMICH_API_URL_EXTERNAL=${IMMICH_API_URL_EXTERNAL}
|
||||
ports:
|
||||
- 3000:3000
|
||||
- 24678:24678
|
||||
|
|
|
@ -7,9 +7,9 @@ services:
|
|||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
command: ["./start-server.sh"]
|
||||
command: [ "./start-server.sh" ]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
|
@ -18,19 +18,6 @@ services:
|
|||
- database
|
||||
- typesense
|
||||
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
image: immich-machine-learning:latest
|
||||
build:
|
||||
context: ../machine-learning
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
|
||||
immich-microservices:
|
||||
container_name: immich_microservices
|
||||
image: immich-microservices:latest
|
||||
|
@ -40,9 +27,9 @@ services:
|
|||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
command: ["./start-microservices.sh"]
|
||||
command: [ "./start-microservices.sh" ]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
|
@ -64,6 +51,18 @@ services:
|
|||
depends_on:
|
||||
- immich-server
|
||||
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
image: immich-machine-learning:latest
|
||||
build:
|
||||
context: ../machine-learning
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
|
||||
typesense:
|
||||
container_name: immich_typesense
|
||||
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
|
||||
|
@ -73,7 +72,7 @@ services:
|
|||
# remove this to get debug messages
|
||||
- GLOG_minloglevel=1
|
||||
volumes:
|
||||
- tsdata:/data
|
||||
- ${UPLOAD_LOCATION}/typesense:/data
|
||||
restart: always
|
||||
|
||||
redis:
|
||||
|
@ -91,7 +90,7 @@ services:
|
|||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
|
||||
restart: always
|
||||
|
||||
immich-proxy:
|
||||
|
@ -113,6 +112,4 @@ services:
|
|||
restart: always
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
model-cache:
|
||||
tsdata:
|
||||
|
|
19
docs/docs/developer/troubleshooting.md
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Troubleshooting
|
||||
|
||||
:::tip
|
||||
A great option to get assistance with troubleshooting is to join our [Discord](https://discord.gg/D8JsnBEuKb) server, where we have a dedicated channel for `#contributing`.
|
||||
:::
|
||||
|
||||
## Known Issues
|
||||
|
||||
### Running on Windows
|
||||
|
||||
Running Immich on Windows can be frustrating and there are lots of ways it can go wrong. Where possible we recommend using Docker on Linux. However, several people have had success running Immich on Windows using Docker via WSL2.
|
||||
|
||||
### NTFS Mounted Volumes
|
||||
|
||||
The docker-compose.dev.yml and docker-compose.prod.yml use volume mounts for the postgres database. On start-up, postgres will try to `chown` the data directory, but fail. See [this post](https://forums.docker.com/t/data-directory-var-lib-postgresql-data-pgdata-has-wrong-ownership/17963/24) for more information about this issue and possible solutions.
|
||||
|
||||
### `Cannot read properties of null (reading 'split')`
|
||||
|
||||
This error occurs when trying to access the app via port `3000` instead of `2283`. During development `immich-proxy` runs on port 2283, while `immich-web` runs on `3000`.
|
|
@ -4,6 +4,10 @@ You can use the CLI to upload an existing gallery to the Immich server
|
|||
|
||||
[Immich CLI Repository](https://github.com/immich-app/CLI)
|
||||
|
||||
:::tip Google Photos Takeout
|
||||
If you are looking to import your Google Photos takeout, we recommed this community maintained tool [immich-go](https://github.com/simulot/immich-go)
|
||||
:::
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 16 or above
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# Facial Recognition
|
||||
|
||||
## Overview
|
||||
|
||||
Immich recognizes faces in your photos and videos and groups them together. You can then assign names to the faces and search for them.
|
||||
|
||||
The list of people is shown in the Explore page.
|
||||
|
@ -13,3 +15,16 @@ Upon clicking on a person, a list of assets that contain their face will be show
|
|||
The asset detail view will also show the faces that are recognized in the asset.
|
||||
|
||||
<img src={require('./img/facial-recognition-3.png').default} title='Facial Recognition 3' />
|
||||
|
||||
## Actions
|
||||
|
||||
Additional actions you can do with a detected person are:
|
||||
|
||||
- Change the feature face photo of the person
|
||||
- Set date of birth
|
||||
- Merge two or more detected faces into one person
|
||||
- Hide face
|
||||
|
||||
It can be found from the app bar when you access the detial view of a person
|
||||
|
||||
<img src={require('./img/facial-recognition-4.png').default} title='Facial Recognition 4' width="70%"/>
|
||||
|
|
BIN
docs/docs/features/img/facial-recognition-4.png
Normal file
After Width: | Height: | Size: 416 KiB |
|
@ -1,6 +1,6 @@
|
|||
import MobileAppDownload from '../partials/_mobile-app-download.md';
|
||||
import MobileAppLogin from '../partials/_mobile-app-login.md';
|
||||
import MobileAppBackup from '../partials/_mobile-app-login.md';
|
||||
import MobileAppBackup from '../partials/_mobile-app-backup.md';
|
||||
|
||||
# Mobile App
|
||||
|
||||
|
|
42
docs/docs/guides/python-file-upload.md
Normal file
|
@ -0,0 +1,42 @@
|
|||
# Python File Upload
|
||||
|
||||
```python
|
||||
#!/usr/bin/python3
|
||||
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
API_KEY = 'YOUR_API_KEY' # replace with a valid api key
|
||||
BASE_URL = 'http://127.0.0.1:2283/api' # replace as needed
|
||||
|
||||
|
||||
def upload(file):
|
||||
stats = os.stat(file)
|
||||
|
||||
headers = {
|
||||
'Accept': 'application/json',
|
||||
'x-api-key': API_KEY
|
||||
}
|
||||
|
||||
data = {
|
||||
'deviceAssetId': f'{file}-{stats.st_mtime}',
|
||||
'deviceId': 'python',
|
||||
'fileCreatedAt': datetime.fromtimestamp(stats.st_mtime),
|
||||
'fileModifiedAt': datetime.fromtimestamp(stats.st_mtime),
|
||||
'isFavorite': 'false',
|
||||
}
|
||||
|
||||
files = {
|
||||
'assetData': open(file, 'rb')
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f'{BASE_URL}/asset/upload', headers=headers, data=data, files=files)
|
||||
|
||||
print(response.json())
|
||||
# {'id': 'ef96f635-61c7-4639-9e60-61a11c4bbfba', 'duplicate': False}
|
||||
|
||||
|
||||
upload('./test.jpg')
|
||||
```
|
|
@ -17,6 +17,12 @@ The default configuration looks like this:
|
|||
"targetAudioCodec": "aac",
|
||||
"targetResolution": "720",
|
||||
"maxBitrate": "0",
|
||||
"bframes": -1,
|
||||
"refs": 0,
|
||||
"gopSize": 0,
|
||||
"npl": 0,
|
||||
"temporalAQ": false,
|
||||
"cqMode": "auto",
|
||||
"twoPass": false,
|
||||
"transcode": "required",
|
||||
"tonemap": "hable",
|
||||
|
@ -44,9 +50,15 @@ The default configuration looks like this:
|
|||
"sidecar": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"library": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"storageTemplateMigration": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"migration": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"thumbnailGeneration": {
|
||||
"concurrency": 5
|
||||
},
|
||||
|
@ -55,16 +67,16 @@ The default configuration looks like this:
|
|||
}
|
||||
},
|
||||
"machineLearning": {
|
||||
"classification": {
|
||||
"minScore": 0.7,
|
||||
"enabled": true,
|
||||
"modelName": "microsoft/resnet-50"
|
||||
},
|
||||
"enabled": true,
|
||||
"url": "http://immich-machine-learning:3003",
|
||||
"classification": {
|
||||
"enabled": true,
|
||||
"modelName": "microsoft/resnet-50",
|
||||
"minScore": 0.9
|
||||
},
|
||||
"clip": {
|
||||
"enabled": true,
|
||||
"modelName": "ViT-B-32::openai"
|
||||
"modelName": "ViT-B-32__openai"
|
||||
},
|
||||
"facialRecognition": {
|
||||
"enabled": true,
|
||||
|
@ -74,6 +86,14 @@ The default configuration looks like this:
|
|||
"minFaces": 1
|
||||
}
|
||||
},
|
||||
"map": {
|
||||
"enabled": true,
|
||||
"tileUrl": "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
},
|
||||
"reverseGeocoding": {
|
||||
"enabled": true,
|
||||
"citiesFileOverride": "cities500"
|
||||
},
|
||||
"oauth": {
|
||||
"enabled": false,
|
||||
"issuerUrl": "",
|
||||
|
@ -96,8 +116,27 @@ The default configuration looks like this:
|
|||
"thumbnail": {
|
||||
"webpSize": 250,
|
||||
"jpegSize": 1440,
|
||||
"quality": 90,
|
||||
"quality": 80,
|
||||
"colorspace": "p3"
|
||||
},
|
||||
"newVersionCheck": {
|
||||
"enabled": true
|
||||
},
|
||||
"trash": {
|
||||
"enabled": true,
|
||||
"days": 30
|
||||
},
|
||||
"theme": {
|
||||
"customCss": ""
|
||||
},
|
||||
"library": {
|
||||
"scan": {
|
||||
"enabled": true,
|
||||
"cronExpression": "0 0 * * *"
|
||||
}
|
||||
},
|
||||
"stylesheets": {
|
||||
"css": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 137 KiB |
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 404 KiB |
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 334 KiB |
|
@ -34,7 +34,7 @@ function HomepageHeader() {
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
<img src="/img/immich-screenshots.png" alt="logo" />
|
||||
<img src="/img/immich-screenshots.png" alt="screenshots" width={'85%'} />
|
||||
|
||||
<div className="flex flex-col sm:flex-row place-items-center place-content-center mt-4 gap-1">
|
||||
<div className="h-24">
|
||||
|
|
BIN
docs/static/img/immich-screenshots.png
vendored
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.8 MiB |
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.84.0"
|
||||
version = "1.85.0"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
|
|
|
@ -35,8 +35,8 @@ platform :android do
|
|||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 108,
|
||||
"android.injected.version.name" => "1.84.0",
|
||||
"android.injected.version.code" => 109,
|
||||
"android.injected.version.name" => "1.85.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
|
|
@ -373,5 +373,11 @@
|
|||
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
||||
"app_bar_signout_dialog_title": "Sign out",
|
||||
"app_bar_signout_dialog_content": "Are you sure you wanna sign out?",
|
||||
"app_bar_signout_dialog_ok": "Yes"
|
||||
"app_bar_signout_dialog_ok": "Yes",
|
||||
"shared_album_activities_input_hint": "Say something",
|
||||
"shared_album_activity_remove_title": "Delete Activity",
|
||||
"shared_album_activity_remove_content": "Do you want to delete this activity?",
|
||||
"shared_album_activity_setting_title": "Comments & likes",
|
||||
"shared_album_activity_setting_subtitle": "Let others respond",
|
||||
"shared_album_activities_input_disable": "Comment is disabled"
|
||||
}
|
||||
|
|
|
@ -169,4 +169,4 @@ SPEC CHECKSUMS:
|
|||
|
||||
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
|
||||
|
||||
COCOAPODS: 1.12.1
|
||||
COCOAPODS: 1.11.3
|
||||
|
|
|
@ -19,7 +19,7 @@ platform :ios do
|
|||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.84.0"
|
||||
version_number: "1.85.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
|
90
mobile/lib/modules/activities/models/activity.model.dart
Normal file
|
@ -0,0 +1,90 @@
|
|||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
enum ActivityType { comment, like }
|
||||
|
||||
class Activity {
|
||||
final String id;
|
||||
final String? assetId;
|
||||
final String? comment;
|
||||
final DateTime createdAt;
|
||||
final ActivityType type;
|
||||
final User user;
|
||||
|
||||
const Activity({
|
||||
required this.id,
|
||||
this.assetId,
|
||||
this.comment,
|
||||
required this.createdAt,
|
||||
required this.type,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
Activity copyWith({
|
||||
String? id,
|
||||
String? assetId,
|
||||
String? comment,
|
||||
DateTime? createdAt,
|
||||
ActivityType? type,
|
||||
User? user,
|
||||
}) {
|
||||
return Activity(
|
||||
id: id ?? this.id,
|
||||
assetId: assetId ?? this.assetId,
|
||||
comment: comment ?? this.comment,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
type: type ?? this.type,
|
||||
user: user ?? this.user,
|
||||
);
|
||||
}
|
||||
|
||||
Activity.fromDto(ActivityResponseDto dto)
|
||||
: id = dto.id,
|
||||
assetId = dto.assetId,
|
||||
comment = dto.comment,
|
||||
createdAt = dto.createdAt,
|
||||
type = dto.type == ActivityResponseDtoTypeEnum.comment
|
||||
? ActivityType.comment
|
||||
: ActivityType.like,
|
||||
user = User(
|
||||
email: dto.user.email,
|
||||
firstName: dto.user.firstName,
|
||||
lastName: dto.user.lastName,
|
||||
profileImagePath: dto.user.profileImagePath,
|
||||
id: dto.user.id,
|
||||
// Placeholder values
|
||||
isAdmin: false,
|
||||
updatedAt: DateTime.now(),
|
||||
isPartnerSharedBy: false,
|
||||
isPartnerSharedWith: false,
|
||||
memoryEnabled: false,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is Activity &&
|
||||
other.id == id &&
|
||||
other.assetId == assetId &&
|
||||
other.comment == comment &&
|
||||
other.createdAt == createdAt &&
|
||||
other.type == type &&
|
||||
other.user == user;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
assetId.hashCode ^
|
||||
comment.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
type.hashCode ^
|
||||
user.hashCode;
|
||||
}
|
||||
}
|
130
mobile/lib/modules/activities/providers/activity.provider.dart
Normal file
|
@ -0,0 +1,130 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
|
||||
import 'package:immich_mobile/modules/activities/services/activity.service.dart';
|
||||
|
||||
class ActivityNotifier extends StateNotifier<AsyncValue<List<Activity>>> {
|
||||
final Ref _ref;
|
||||
final ActivityService _activityService;
|
||||
final String albumId;
|
||||
final String? assetId;
|
||||
|
||||
ActivityNotifier(
|
||||
this._ref,
|
||||
this._activityService,
|
||||
this.albumId,
|
||||
this.assetId,
|
||||
) : super(
|
||||
const AsyncData([]),
|
||||
) {
|
||||
fetchActivity();
|
||||
}
|
||||
|
||||
Future<void> fetchActivity() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() => _activityService.getAllActivities(albumId, assetId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeActivity(String id) async {
|
||||
final activities = state.asData?.value ?? [];
|
||||
if (await _activityService.removeActivity(id)) {
|
||||
final removedActivity = activities.firstWhere((a) => a.id == id);
|
||||
activities.remove(removedActivity);
|
||||
state = AsyncData(activities);
|
||||
if (removedActivity.type == ActivityType.comment) {
|
||||
_ref
|
||||
.read(
|
||||
activityStatisticsStateProvider(
|
||||
(albumId: albumId, assetId: assetId),
|
||||
).notifier,
|
||||
)
|
||||
.removeActivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addComment(String comment) async {
|
||||
final activity = await _activityService.addActivity(
|
||||
albumId,
|
||||
ActivityType.comment,
|
||||
assetId: assetId,
|
||||
comment: comment,
|
||||
);
|
||||
|
||||
if (activity != null) {
|
||||
final activities = state.asData?.value ?? [];
|
||||
state = AsyncData([...activities, activity]);
|
||||
_ref
|
||||
.read(
|
||||
activityStatisticsStateProvider(
|
||||
(albumId: albumId, assetId: assetId),
|
||||
).notifier,
|
||||
)
|
||||
.addActivity();
|
||||
if (assetId != null) {
|
||||
// Add a count to the current album's provider as well
|
||||
_ref
|
||||
.read(
|
||||
activityStatisticsStateProvider(
|
||||
(albumId: albumId, assetId: null),
|
||||
).notifier,
|
||||
)
|
||||
.addActivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addLike() async {
|
||||
final activity = await _activityService
|
||||
.addActivity(albumId, ActivityType.like, assetId: assetId);
|
||||
if (activity != null) {
|
||||
final activities = state.asData?.value ?? [];
|
||||
state = AsyncData([...activities, activity]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ActivityStatisticsNotifier extends StateNotifier<int> {
|
||||
final String albumId;
|
||||
final String? assetId;
|
||||
final ActivityService _activityService;
|
||||
ActivityStatisticsNotifier(this._activityService, this.albumId, this.assetId)
|
||||
: super(0) {
|
||||
fetchStatistics();
|
||||
}
|
||||
|
||||
Future<void> fetchStatistics() async {
|
||||
state = await _activityService.getStatistics(albumId, assetId: assetId);
|
||||
}
|
||||
|
||||
Future<void> addActivity() async {
|
||||
state = state + 1;
|
||||
}
|
||||
|
||||
Future<void> removeActivity() async {
|
||||
state = state - 1;
|
||||
}
|
||||
}
|
||||
|
||||
typedef ActivityParams = ({String albumId, String? assetId});
|
||||
|
||||
final activityStateProvider = StateNotifierProvider.autoDispose
|
||||
.family<ActivityNotifier, AsyncValue<List<Activity>>, ActivityParams>(
|
||||
(ref, args) {
|
||||
return ActivityNotifier(
|
||||
ref,
|
||||
ref.watch(activityServiceProvider),
|
||||
args.albumId,
|
||||
args.assetId,
|
||||
);
|
||||
});
|
||||
|
||||
final activityStatisticsStateProvider = StateNotifierProvider.autoDispose
|
||||
.family<ActivityStatisticsNotifier, int, ActivityParams>((ref, args) {
|
||||
return ActivityStatisticsNotifier(
|
||||
ref.watch(activityServiceProvider),
|
||||
args.albumId,
|
||||
args.assetId,
|
||||
);
|
||||
});
|
85
mobile/lib/modules/activities/services/activity.service.dart
Normal file
|
@ -0,0 +1,85 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final activityServiceProvider =
|
||||
Provider((ref) => ActivityService(ref.watch(apiServiceProvider)));
|
||||
|
||||
class ActivityService {
|
||||
final ApiService _apiService;
|
||||
final Logger _log = Logger("ActivityService");
|
||||
|
||||
ActivityService(this._apiService);
|
||||
|
||||
Future<List<Activity>> getAllActivities(
|
||||
String albumId,
|
||||
String? assetId,
|
||||
) async {
|
||||
try {
|
||||
final list = await _apiService.activityApi
|
||||
.getActivities(albumId, assetId: assetId);
|
||||
return list != null ? list.map(Activity.fromDto).toList() : [];
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"failed to fetch activities for albumId - $albumId; assetId - $assetId -> $e",
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> getStatistics(String albumId, {String? assetId}) async {
|
||||
try {
|
||||
final dto = await _apiService.activityApi
|
||||
.getActivityStatistics(albumId, assetId: assetId);
|
||||
return dto?.comments ?? 0;
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"failed to fetch activity statistics for albumId - $albumId; assetId - $assetId -> $e",
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
Future<bool> removeActivity(String id) async {
|
||||
try {
|
||||
await _apiService.activityApi.deleteActivity(id);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"failed to remove activity id - $id -> $e",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<Activity?> addActivity(
|
||||
String albumId,
|
||||
ActivityType type, {
|
||||
String? assetId,
|
||||
String? comment,
|
||||
}) async {
|
||||
try {
|
||||
final dto = await _apiService.activityApi.createActivity(
|
||||
ActivityCreateDto(
|
||||
albumId: albumId,
|
||||
type: type == ActivityType.comment
|
||||
? ReactionType.comment
|
||||
: ReactionType.like,
|
||||
assetId: assetId,
|
||||
comment: comment,
|
||||
),
|
||||
);
|
||||
if (dto != null) {
|
||||
return Activity.fromDto(dto);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
"failed to add activity for albumId - $albumId; assetId - $assetId -> $e",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
320
mobile/lib/modules/activities/views/activities_page.dart
Normal file
|
@ -0,0 +1,320 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/confirm_dialog.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/utils/datetime_extensions.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class ActivitiesPage extends HookConsumerWidget {
|
||||
final String albumId;
|
||||
final String? assetId;
|
||||
final bool withAssetThumbs;
|
||||
final String appBarTitle;
|
||||
final bool isOwner;
|
||||
final bool isReadOnly;
|
||||
const ActivitiesPage(
|
||||
this.albumId, {
|
||||
this.appBarTitle = "",
|
||||
this.assetId,
|
||||
this.withAssetThumbs = true,
|
||||
this.isOwner = false,
|
||||
this.isReadOnly = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider =
|
||||
activityStateProvider((albumId: albumId, assetId: assetId));
|
||||
final activities = ref.watch(provider);
|
||||
final inputController = useTextEditingController();
|
||||
final inputFocusNode = useFocusNode();
|
||||
final listViewScrollController = useScrollController();
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
inputFocusNode.requestFocus();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
buildTitleWithTimestamp(Activity activity, {bool leftAlign = true}) {
|
||||
final textColor = Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black;
|
||||
final textStyle = Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: textColor.withOpacity(0.6));
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: leftAlign
|
||||
? MainAxisAlignment.start
|
||||
: MainAxisAlignment.spaceBetween,
|
||||
mainAxisSize: leftAlign ? MainAxisSize.min : MainAxisSize.max,
|
||||
children: [
|
||||
Text(
|
||||
"${activity.user.firstName} ${activity.user.lastName}",
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (leftAlign)
|
||||
Text(
|
||||
" • ",
|
||||
style: textStyle,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
activity.createdAt.copyWith().timeAgo(),
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: leftAlign ? TextAlign.left : TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
buildAssetThumbnail(Activity activity) {
|
||||
return withAssetThumbs && activity.assetId != null
|
||||
? Container(
|
||||
width: 40,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
getThumbnailUrlForRemoteId(
|
||||
activity.assetId!,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKeyForRemoteId(
|
||||
activity.assetId!,
|
||||
),
|
||||
headers: {
|
||||
"Authorization":
|
||||
'Bearer ${Store.get(StoreKey.accessToken)}',
|
||||
},
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: const SizedBox.shrink(),
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
buildTextField(String? likedId) {
|
||||
final liked = likedId != null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: TextField(
|
||||
controller: inputController,
|
||||
enabled: !isReadOnly,
|
||||
focusNode: inputFocusNode,
|
||||
textInputAction: TextInputAction.send,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
prefixIcon: currentUser != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: UserCircleAvatar(
|
||||
user: currentUser,
|
||||
size: 30,
|
||||
radius: 15,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
suffixIcon: Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
liked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
),
|
||||
onPressed: () async {
|
||||
liked
|
||||
? await ref
|
||||
.read(provider.notifier)
|
||||
.removeActivity(likedId)
|
||||
: await ref.read(provider.notifier).addLike();
|
||||
},
|
||||
),
|
||||
),
|
||||
suffixIconColor: liked ? Colors.red[700] : null,
|
||||
hintText: isReadOnly
|
||||
? 'shared_album_activities_input_disable'.tr()
|
||||
: 'shared_album_activities_input_hint'.tr(),
|
||||
hintStyle: TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
onEditingComplete: () async {
|
||||
await ref.read(provider.notifier).addComment(inputController.text);
|
||||
inputController.clear();
|
||||
inputFocusNode.unfocus();
|
||||
listViewScrollController.animateTo(
|
||||
listViewScrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
},
|
||||
onTapOutside: (_) => inputFocusNode.unfocus(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getDismissibleWidget(
|
||||
Widget widget,
|
||||
Activity activity,
|
||||
bool canDelete,
|
||||
) {
|
||||
return Dismissible(
|
||||
key: Key(activity.id),
|
||||
dismissThresholds: const {
|
||||
DismissDirection.horizontal: 0.7,
|
||||
},
|
||||
direction: DismissDirection.horizontal,
|
||||
confirmDismiss: (direction) => canDelete
|
||||
? showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
onOk: () {},
|
||||
title: "shared_album_activity_remove_title",
|
||||
content: "shared_album_activity_remove_content",
|
||||
ok: "delete_dialog_ok",
|
||||
),
|
||||
)
|
||||
: Future.value(false),
|
||||
onDismissed: (direction) async =>
|
||||
await ref.read(provider.notifier).removeActivity(activity.id),
|
||||
background: Container(
|
||||
color: canDelete ? Colors.red[400] : Colors.grey[600],
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: canDelete
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(15),
|
||||
child: Icon(
|
||||
Icons.delete_sweep_rounded,
|
||||
color: Colors.black,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
secondaryBackground: Container(
|
||||
color: canDelete ? Colors.red[400] : Colors.grey[600],
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: canDelete
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(15),
|
||||
child: Icon(
|
||||
Icons.delete_sweep_rounded,
|
||||
color: Colors.black,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(appBarTitle)),
|
||||
body: activities.maybeWhen(
|
||||
orElse: () {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
},
|
||||
data: (data) {
|
||||
final liked = data.firstWhereOrNull(
|
||||
(a) =>
|
||||
a.type == ActivityType.like &&
|
||||
a.user.id == currentUser?.id &&
|
||||
a.assetId == assetId,
|
||||
);
|
||||
|
||||
return SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
controller: listViewScrollController,
|
||||
itemCount: data.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
// Vertical gap after the last element
|
||||
if (index == data.length) {
|
||||
return const SizedBox(
|
||||
height: 80,
|
||||
);
|
||||
}
|
||||
|
||||
final activity = data[index];
|
||||
final canDelete =
|
||||
activity.user.id == currentUser?.id || isOwner;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: activity.type == ActivityType.comment
|
||||
? getDismissibleWidget(
|
||||
ListTile(
|
||||
minVerticalPadding: 15,
|
||||
leading: UserCircleAvatar(user: activity.user),
|
||||
title: buildTitleWithTimestamp(
|
||||
activity,
|
||||
leftAlign: withAssetThumbs &&
|
||||
activity.assetId != null,
|
||||
),
|
||||
titleAlignment: ListTileTitleAlignment.top,
|
||||
trailing: buildAssetThumbnail(activity),
|
||||
subtitle: Text(activity.comment!),
|
||||
),
|
||||
activity,
|
||||
canDelete,
|
||||
)
|
||||
: getDismissibleWidget(
|
||||
ListTile(
|
||||
minVerticalPadding: 15,
|
||||
leading: Container(
|
||||
width: 44,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: Colors.red[700],
|
||||
),
|
||||
),
|
||||
title: buildTitleWithTimestamp(activity),
|
||||
trailing: buildAssetThumbnail(activity),
|
||||
),
|
||||
activity,
|
||||
canDelete,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: buildTextField(liked?.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
@ -10,7 +11,7 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
|
|||
import 'package:isar/isar.dart';
|
||||
|
||||
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
|
||||
SharedAlbumNotifier(this._albumService, Isar db, this._ref) : super([]) {
|
||||
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
|
||||
query.findAll().then((value) => state = value);
|
||||
_streamSub = query.watch().listen((data) => state = data);
|
||||
|
@ -18,6 +19,7 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
|||
|
||||
final AlbumService _albumService;
|
||||
late final StreamSubscription<List<Album>> _streamSub;
|
||||
final Ref _ref;
|
||||
|
||||
Future<Album?> createSharedAlbum(
|
||||
String albumName,
|
||||
|
@ -66,6 +68,17 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
|||
return result;
|
||||
}
|
||||
|
||||
Future<bool> setActivityEnabled(Album album, bool activityEnabled) async {
|
||||
final result =
|
||||
await _albumService.setActivityEnabled(album, activityEnabled);
|
||||
|
||||
if (result) {
|
||||
_ref.invalidate(albumDetailProvider(album.id));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSub.cancel();
|
||||
|
@ -78,5 +91,6 @@ final sharedAlbumProvider =
|
|||
return SharedAlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
||||
|
|
|
@ -284,6 +284,23 @@ class AlbumService {
|
|||
return false;
|
||||
}
|
||||
|
||||
Future<bool> setActivityEnabled(Album album, bool enabled) async {
|
||||
try {
|
||||
final result = await _apiService.albumApi.updateAlbumInfo(
|
||||
album.remoteId!,
|
||||
UpdateAlbumDto(isActivityEnabled: enabled),
|
||||
);
|
||||
if (result != null) {
|
||||
album.activityEnabled = enabled;
|
||||
await _db.writeTxn(() => _db.albums.put(album));
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error setActivityEnabled ${e.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> deleteAlbum(Album album) async {
|
||||
try {
|
||||
final userId = Store.get(StoreKey.currentUser).isarId;
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
||||
|
@ -26,6 +27,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
|||
required this.titleFocusNode,
|
||||
this.onAddPhotos,
|
||||
this.onAddUsers,
|
||||
required this.onActivities,
|
||||
}) : super(key: key);
|
||||
|
||||
final Album album;
|
||||
|
@ -35,11 +37,19 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
|||
final FocusNode titleFocusNode;
|
||||
final Function(Album album)? onAddPhotos;
|
||||
final Function(Album album)? onAddUsers;
|
||||
final Function(Album album) onActivities;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
|
||||
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
|
||||
final comments = album.shared
|
||||
? ref.watch(
|
||||
activityStatisticsStateProvider(
|
||||
(albumId: album.remoteId!, assetId: null),
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
|
||||
deleteAlbum() async {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
@ -206,32 +216,36 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
|||
).tr(),
|
||||
onTap: () => onShareAssetsTo(),
|
||||
),
|
||||
album.ownerId == userId ? ListTile(
|
||||
leading: const Icon(Icons.delete_sweep_rounded),
|
||||
title: const Text(
|
||||
'album_viewer_appbar_share_remove',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () => onRemoveFromAlbumPressed(),
|
||||
) : const SizedBox(),
|
||||
album.ownerId == userId
|
||||
? ListTile(
|
||||
leading: const Icon(Icons.delete_sweep_rounded),
|
||||
title: const Text(
|
||||
'album_viewer_appbar_share_remove',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () => onRemoveFromAlbumPressed(),
|
||||
)
|
||||
: const SizedBox(),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
album.ownerId == userId ? ListTile(
|
||||
leading: const Icon(Icons.delete_forever_rounded),
|
||||
title: const Text(
|
||||
'album_viewer_appbar_share_delete',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () => onDeleteAlbumPressed(),
|
||||
) : ListTile(
|
||||
leading: const Icon(Icons.person_remove_rounded),
|
||||
title: const Text(
|
||||
'album_viewer_appbar_share_leave',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () => onLeaveAlbumPressed(),
|
||||
),
|
||||
album.ownerId == userId
|
||||
? ListTile(
|
||||
leading: const Icon(Icons.delete_forever_rounded),
|
||||
title: const Text(
|
||||
'album_viewer_appbar_share_delete',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () => onDeleteAlbumPressed(),
|
||||
)
|
||||
: ListTile(
|
||||
leading: const Icon(Icons.person_remove_rounded),
|
||||
title: const Text(
|
||||
'album_viewer_appbar_share_leave',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () => onLeaveAlbumPressed(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -310,6 +324,33 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
|||
);
|
||||
}
|
||||
|
||||
Widget buildActivitiesButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onActivities(album);
|
||||
},
|
||||
icon: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.mode_comment_outlined,
|
||||
),
|
||||
if (comments != 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 5),
|
||||
child: Text(
|
||||
comments.toString(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildLeadingButton() {
|
||||
if (selected.isNotEmpty) {
|
||||
return IconButton(
|
||||
|
@ -353,6 +394,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
|||
title: selected.isNotEmpty ? Text('${selected.length}') : null,
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
if (album.shared && (album.activityEnabled || comments != 0))
|
||||
buildActivitiesButton(),
|
||||
if (album.isRemote)
|
||||
IconButton(
|
||||
splashRadius: 25,
|
||||
|
|
|
@ -23,6 +23,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
|||
final sharedUsers = useState(album.sharedUsers.toList());
|
||||
final owner = album.owner.value;
|
||||
final userId = ref.watch(authenticationProvider).userId;
|
||||
final activityEnabled = useState(album.activityEnabled);
|
||||
final isOwner = owner?.id == userId;
|
||||
|
||||
void showErrorMessage() {
|
||||
|
@ -190,6 +191,31 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
|||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isOwner && album.shared)
|
||||
SwitchListTile.adaptive(
|
||||
value: activityEnabled.value,
|
||||
onChanged: (bool value) async {
|
||||
activityEnabled.value = value;
|
||||
if (await ref
|
||||
.read(sharedAlbumProvider.notifier)
|
||||
.setActivityEnabled(album, value)) {
|
||||
album.activityEnabled = value;
|
||||
}
|
||||
},
|
||||
activeColor: activityEnabled.value
|
||||
? Theme.of(context).primaryColor
|
||||
: Theme.of(context).disabledColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"shared_album_activity_setting_title",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
subtitle:
|
||||
const Text("shared_album_activity_setting_subtitle").tr(),
|
||||
),
|
||||
buildSectionTitle("PEOPLE"),
|
||||
buildOwnerInfo(),
|
||||
buildSharedUsersList(),
|
||||
|
|
|
@ -231,6 +231,19 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
onActivitiesPressed(Album album) {
|
||||
if (album.remoteId != null) {
|
||||
AutoRouter.of(context).push(
|
||||
ActivitiesRoute(
|
||||
albumId: album.remoteId!,
|
||||
appBarTitle: album.name,
|
||||
isOwner: userId == album.ownerId,
|
||||
isReadOnly: !album.activityEnabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: album.when(
|
||||
data: (data) => AlbumViewerAppbar(
|
||||
|
@ -241,6 +254,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||
selectionDisabled: disableSelection,
|
||||
onAddPhotos: onAddPhotosPressed,
|
||||
onAddUsers: onAddUsersPressed,
|
||||
onActivities: onActivitiesPressed,
|
||||
),
|
||||
error: (error, stackTrace) => AppBar(title: const Text("Error")),
|
||||
loading: () => AppBar(),
|
||||
|
@ -265,6 +279,8 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||
],
|
||||
),
|
||||
isOwner: userId == data.ownerId,
|
||||
sharedAlbumId:
|
||||
data.shared && data.activityEnabled ? data.remoteId : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
|
||||
|
@ -16,6 +17,8 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||
required this.onFavorite,
|
||||
required this.onUploadPressed,
|
||||
required this.isOwner,
|
||||
required this.shareAlbumId,
|
||||
required this.onActivitiesPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
final Asset asset;
|
||||
|
@ -24,14 +27,23 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||
final VoidCallback? onDownloadPressed;
|
||||
final VoidCallback onToggleMotionVideo;
|
||||
final VoidCallback onAddToAlbumPressed;
|
||||
final VoidCallback onActivitiesPressed;
|
||||
final Function(Asset) onFavorite;
|
||||
final bool isPlayingMotionVideo;
|
||||
final bool isOwner;
|
||||
final String? shareAlbumId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
const double iconSize = 22.0;
|
||||
final a = ref.watch(assetWatcher(asset)).value ?? asset;
|
||||
final comments = shareAlbumId != null
|
||||
? ref.watch(
|
||||
activityStatisticsStateProvider(
|
||||
(albumId: shareAlbumId!, assetId: asset.remoteId),
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
|
||||
Widget buildFavoriteButton(a) {
|
||||
return IconButton(
|
||||
|
@ -94,6 +106,34 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget buildActivitiesButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onActivitiesPressed();
|
||||
},
|
||||
icon: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.mode_comment_outlined,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
if (comments != 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 5),
|
||||
child: Text(
|
||||
comments.toString(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildUploadButton() {
|
||||
return IconButton(
|
||||
onPressed: onUploadPressed,
|
||||
|
@ -130,6 +170,7 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
||||
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
|
||||
if (asset.isRemote && isOwner) buildAddToAlbumButtom(),
|
||||
if (shareAlbumId != null) buildActivitiesButton(),
|
||||
buildMoreInfoButton(),
|
||||
],
|
||||
);
|
||||
|
|
|
@ -49,6 +49,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
final int heroOffset;
|
||||
final bool showStack;
|
||||
final bool isOwner;
|
||||
final String? sharedAlbumId;
|
||||
|
||||
GalleryViewerPage({
|
||||
super.key,
|
||||
|
@ -58,6 +59,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
this.heroOffset = 0,
|
||||
this.showStack = false,
|
||||
this.isOwner = true,
|
||||
this.sharedAlbumId,
|
||||
}) : controller = PageController(initialPage: initialIndex);
|
||||
|
||||
final PageController controller;
|
||||
|
@ -327,6 +329,19 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
handleActivities() {
|
||||
if (sharedAlbumId != null) {
|
||||
AutoRouter.of(context).push(
|
||||
ActivitiesRoute(
|
||||
albumId: sharedAlbumId!,
|
||||
assetId: asset().remoteId,
|
||||
withAssetThumbs: false,
|
||||
isOwner: isOwner,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
buildAppBar() {
|
||||
return IgnorePointer(
|
||||
ignoring: !ref.watch(showControlsProvider),
|
||||
|
@ -355,6 +370,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
|
||||
}),
|
||||
onAddToAlbumPressed: () => addToAlbum(asset()),
|
||||
shareAlbumId: sharedAlbumId,
|
||||
onActivitiesPressed: handleActivities,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -34,6 +34,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
|||
final bool showDragScroll;
|
||||
final bool showStack;
|
||||
final bool isOwner;
|
||||
final String? sharedAlbumId;
|
||||
|
||||
const ImmichAssetGrid({
|
||||
super.key,
|
||||
|
@ -55,6 +56,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
|||
this.showDragScroll = true,
|
||||
this.showStack = false,
|
||||
this.isOwner = true,
|
||||
this.sharedAlbumId,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -120,6 +122,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
|||
showDragScroll: showDragScroll,
|
||||
showStack: showStack,
|
||||
isOwner: isOwner,
|
||||
sharedAlbumId: sharedAlbumId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -39,6 +39,7 @@ class ImmichAssetGridView extends StatefulWidget {
|
|||
final bool showDragScroll;
|
||||
final bool showStack;
|
||||
final bool isOwner;
|
||||
final String? sharedAlbumId;
|
||||
|
||||
const ImmichAssetGridView({
|
||||
super.key,
|
||||
|
@ -60,6 +61,7 @@ class ImmichAssetGridView extends StatefulWidget {
|
|||
this.showDragScroll = true,
|
||||
this.showStack = false,
|
||||
this.isOwner = true,
|
||||
this.sharedAlbumId,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -141,6 +143,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||
heroOffset: widget.heroOffset,
|
||||
showStack: widget.showStack,
|
||||
isOwner: widget.isOwner,
|
||||
sharedAlbumId: widget.sharedAlbumId,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ class ThumbnailImage extends StatelessWidget {
|
|||
final Function? onSelect;
|
||||
final Function? onDeselect;
|
||||
final int heroOffset;
|
||||
final String? sharedAlbumId;
|
||||
|
||||
const ThumbnailImage({
|
||||
Key? key,
|
||||
|
@ -31,6 +32,7 @@ class ThumbnailImage extends StatelessWidget {
|
|||
this.showStorageIndicator = true,
|
||||
this.showStack = false,
|
||||
this.isOwner = true,
|
||||
this.sharedAlbumId,
|
||||
this.useGrayBoxPlaceholder = false,
|
||||
this.isSelected = false,
|
||||
this.multiselectEnabled = false,
|
||||
|
@ -184,6 +186,7 @@ class ThumbnailImage extends StatelessWidget {
|
|||
heroOffset: heroOffset,
|
||||
showStack: showStack,
|
||||
isOwner: isOwner,
|
||||
sharedAlbumId: sharedAlbumId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -172,7 +172,7 @@ class SearchPage extends HookConsumerWidget {
|
|||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.star_outline,
|
||||
Icons.favorite_border_rounded,
|
||||
color: categoryIconColor,
|
||||
),
|
||||
title:
|
||||
|
|
|
@ -37,6 +37,7 @@ class TrashNotifier extends StateNotifier<bool> {
|
|||
.remoteIdProperty()
|
||||
.findAll();
|
||||
|
||||
// TODO: handle local asset removal on emptyTrash
|
||||
_ref
|
||||
.read(syncServiceProvider)
|
||||
.handleRemoteAssetRemoval(idsToRemove.cast<String>().toList());
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/views/activities_page.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/views/album_options_part.dart';
|
||||
import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
|
||||
|
@ -160,6 +161,12 @@ part 'router.gr.dart';
|
|||
AutoRoute(page: TrashPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: SharedLinkPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: SharedLinkEditPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
CustomRoute(
|
||||
page: ActivitiesPage,
|
||||
guards: [AuthGuard, DuplicateGuard],
|
||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||
durationInMilliseconds: 200,
|
||||
),
|
||||
],
|
||||
)
|
||||
class AppRouter extends _$AppRouter {
|
||||
|
|
|
@ -73,6 +73,7 @@ class _$AppRouter extends RootStackRouter {
|
|||
heroOffset: args.heroOffset,
|
||||
showStack: args.showStack,
|
||||
isOwner: args.isOwner,
|
||||
sharedAlbumId: args.sharedAlbumId,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -337,6 +338,25 @@ class _$AppRouter extends RootStackRouter {
|
|||
),
|
||||
);
|
||||
},
|
||||
ActivitiesRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<ActivitiesRouteArgs>();
|
||||
return CustomPage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: ActivitiesPage(
|
||||
args.albumId,
|
||||
appBarTitle: args.appBarTitle,
|
||||
assetId: args.assetId,
|
||||
withAssetThumbs: args.withAssetThumbs,
|
||||
isOwner: args.isOwner,
|
||||
isReadOnly: args.isReadOnly,
|
||||
key: args.key,
|
||||
),
|
||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||
durationInMilliseconds: 200,
|
||||
opaque: true,
|
||||
barrierDismissible: false,
|
||||
);
|
||||
},
|
||||
HomeRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
|
@ -674,6 +694,14 @@ class _$AppRouter extends RootStackRouter {
|
|||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
ActivitiesRoute.name,
|
||||
path: '/activities-page',
|
||||
guards: [
|
||||
authGuard,
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -749,6 +777,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
|
|||
int heroOffset = 0,
|
||||
bool showStack = false,
|
||||
bool isOwner = true,
|
||||
String? sharedAlbumId,
|
||||
}) : super(
|
||||
GalleryViewerRoute.name,
|
||||
path: '/gallery-viewer-page',
|
||||
|
@ -760,6 +789,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
|
|||
heroOffset: heroOffset,
|
||||
showStack: showStack,
|
||||
isOwner: isOwner,
|
||||
sharedAlbumId: sharedAlbumId,
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -775,6 +805,7 @@ class GalleryViewerRouteArgs {
|
|||
this.heroOffset = 0,
|
||||
this.showStack = false,
|
||||
this.isOwner = true,
|
||||
this.sharedAlbumId,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
@ -791,9 +822,11 @@ class GalleryViewerRouteArgs {
|
|||
|
||||
final bool isOwner;
|
||||
|
||||
final String? sharedAlbumId;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner}';
|
||||
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner, sharedAlbumId: $sharedAlbumId}';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1527,6 +1560,65 @@ class SharedLinkEditRouteArgs {
|
|||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [ActivitiesPage]
|
||||
class ActivitiesRoute extends PageRouteInfo<ActivitiesRouteArgs> {
|
||||
ActivitiesRoute({
|
||||
required String albumId,
|
||||
String appBarTitle = "",
|
||||
String? assetId,
|
||||
bool withAssetThumbs = true,
|
||||
bool isOwner = false,
|
||||
bool isReadOnly = false,
|
||||
Key? key,
|
||||
}) : super(
|
||||
ActivitiesRoute.name,
|
||||
path: '/activities-page',
|
||||
args: ActivitiesRouteArgs(
|
||||
albumId: albumId,
|
||||
appBarTitle: appBarTitle,
|
||||
assetId: assetId,
|
||||
withAssetThumbs: withAssetThumbs,
|
||||
isOwner: isOwner,
|
||||
isReadOnly: isReadOnly,
|
||||
key: key,
|
||||
),
|
||||
);
|
||||
|
||||
static const String name = 'ActivitiesRoute';
|
||||
}
|
||||
|
||||
class ActivitiesRouteArgs {
|
||||
const ActivitiesRouteArgs({
|
||||
required this.albumId,
|
||||
this.appBarTitle = "",
|
||||
this.assetId,
|
||||
this.withAssetThumbs = true,
|
||||
this.isOwner = false,
|
||||
this.isReadOnly = false,
|
||||
this.key,
|
||||
});
|
||||
|
||||
final String albumId;
|
||||
|
||||
final String appBarTitle;
|
||||
|
||||
final String? assetId;
|
||||
|
||||
final bool withAssetThumbs;
|
||||
|
||||
final bool isOwner;
|
||||
|
||||
final bool isReadOnly;
|
||||
|
||||
final Key? key;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ActivitiesRouteArgs{albumId: $albumId, appBarTitle: $appBarTitle, assetId: $assetId, withAssetThumbs: $withAssetThumbs, isOwner: $isOwner, isReadOnly: $isReadOnly, key: $key}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [HomePage]
|
||||
class HomeRoute extends PageRouteInfo<void> {
|
||||
|
|
|
@ -22,6 +22,7 @@ class Album {
|
|||
this.endDate,
|
||||
this.lastModifiedAssetTimestamp,
|
||||
required this.shared,
|
||||
required this.activityEnabled,
|
||||
});
|
||||
|
||||
Id id = Isar.autoIncrement;
|
||||
|
@ -36,6 +37,7 @@ class Album {
|
|||
DateTime? endDate;
|
||||
DateTime? lastModifiedAssetTimestamp;
|
||||
bool shared;
|
||||
bool activityEnabled;
|
||||
final IsarLink<User> owner = IsarLink<User>();
|
||||
final IsarLink<Asset> thumbnail = IsarLink<Asset>();
|
||||
final IsarLinks<User> sharedUsers = IsarLinks<User>();
|
||||
|
@ -77,7 +79,8 @@ class Album {
|
|||
}
|
||||
|
||||
Stream<void> watchRenderList(GroupAssetsBy groupAssetsBy) async* {
|
||||
final query = assets.filter().sortByFileCreatedAtDesc();
|
||||
final query =
|
||||
assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc();
|
||||
_renderList = await RenderList.fromQuery(query, groupAssetsBy);
|
||||
yield _renderList;
|
||||
await for (final _ in query.watchLazy()) {
|
||||
|
@ -105,6 +108,7 @@ class Album {
|
|||
modifiedAt.isAtSameMomentAs(other.modifiedAt) &&
|
||||
lastModifiedAssetTimestampIsSetAndEqual &&
|
||||
shared == other.shared &&
|
||||
activityEnabled == other.activityEnabled &&
|
||||
owner.value == other.owner.value &&
|
||||
thumbnail.value == other.thumbnail.value &&
|
||||
sharedUsers.length == other.sharedUsers.length &&
|
||||
|
@ -122,6 +126,7 @@ class Album {
|
|||
modifiedAt.hashCode ^
|
||||
lastModifiedAssetTimestamp.hashCode ^
|
||||
shared.hashCode ^
|
||||
activityEnabled.hashCode ^
|
||||
owner.value.hashCode ^
|
||||
thumbnail.value.hashCode ^
|
||||
sharedUsers.length.hashCode ^
|
||||
|
@ -133,6 +138,7 @@ class Album {
|
|||
createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
|
||||
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
|
||||
shared: false,
|
||||
activityEnabled: false,
|
||||
);
|
||||
a.owner.value = Store.get(StoreKey.currentUser);
|
||||
a.localId = ape.id;
|
||||
|
@ -150,6 +156,7 @@ class Album {
|
|||
shared: dto.shared,
|
||||
startDate: dto.startDate,
|
||||
endDate: dto.endDate,
|
||||
activityEnabled: dto.isActivityEnabled,
|
||||
);
|
||||
a.owner.value = await db.users.getById(dto.ownerId);
|
||||
if (dto.albumThumbnailAssetId != null) {
|
||||
|
|
|
@ -17,48 +17,53 @@ const AlbumSchema = CollectionSchema(
|
|||
name: r'Album',
|
||||
id: -1355968412107120937,
|
||||
properties: {
|
||||
r'createdAt': PropertySchema(
|
||||
r'activityEnabled': PropertySchema(
|
||||
id: 0,
|
||||
name: r'activityEnabled',
|
||||
type: IsarType.bool,
|
||||
),
|
||||
r'createdAt': PropertySchema(
|
||||
id: 1,
|
||||
name: r'createdAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'endDate': PropertySchema(
|
||||
id: 1,
|
||||
id: 2,
|
||||
name: r'endDate',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'lastModifiedAssetTimestamp': PropertySchema(
|
||||
id: 2,
|
||||
id: 3,
|
||||
name: r'lastModifiedAssetTimestamp',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'localId': PropertySchema(
|
||||
id: 3,
|
||||
id: 4,
|
||||
name: r'localId',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'modifiedAt': PropertySchema(
|
||||
id: 4,
|
||||
id: 5,
|
||||
name: r'modifiedAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'name': PropertySchema(
|
||||
id: 5,
|
||||
id: 6,
|
||||
name: r'name',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'remoteId': PropertySchema(
|
||||
id: 6,
|
||||
id: 7,
|
||||
name: r'remoteId',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'shared': PropertySchema(
|
||||
id: 7,
|
||||
id: 8,
|
||||
name: r'shared',
|
||||
type: IsarType.bool,
|
||||
),
|
||||
r'startDate': PropertySchema(
|
||||
id: 8,
|
||||
id: 9,
|
||||
name: r'startDate',
|
||||
type: IsarType.dateTime,
|
||||
)
|
||||
|
@ -157,15 +162,16 @@ void _albumSerialize(
|
|||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
writer.writeDateTime(offsets[0], object.createdAt);
|
||||
writer.writeDateTime(offsets[1], object.endDate);
|
||||
writer.writeDateTime(offsets[2], object.lastModifiedAssetTimestamp);
|
||||
writer.writeString(offsets[3], object.localId);
|
||||
writer.writeDateTime(offsets[4], object.modifiedAt);
|
||||
writer.writeString(offsets[5], object.name);
|
||||
writer.writeString(offsets[6], object.remoteId);
|
||||
writer.writeBool(offsets[7], object.shared);
|
||||
writer.writeDateTime(offsets[8], object.startDate);
|
||||
writer.writeBool(offsets[0], object.activityEnabled);
|
||||
writer.writeDateTime(offsets[1], object.createdAt);
|
||||
writer.writeDateTime(offsets[2], object.endDate);
|
||||
writer.writeDateTime(offsets[3], object.lastModifiedAssetTimestamp);
|
||||
writer.writeString(offsets[4], object.localId);
|
||||
writer.writeDateTime(offsets[5], object.modifiedAt);
|
||||
writer.writeString(offsets[6], object.name);
|
||||
writer.writeString(offsets[7], object.remoteId);
|
||||
writer.writeBool(offsets[8], object.shared);
|
||||
writer.writeDateTime(offsets[9], object.startDate);
|
||||
}
|
||||
|
||||
Album _albumDeserialize(
|
||||
|
@ -175,15 +181,16 @@ Album _albumDeserialize(
|
|||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
final object = Album(
|
||||
createdAt: reader.readDateTime(offsets[0]),
|
||||
endDate: reader.readDateTimeOrNull(offsets[1]),
|
||||
lastModifiedAssetTimestamp: reader.readDateTimeOrNull(offsets[2]),
|
||||
localId: reader.readStringOrNull(offsets[3]),
|
||||
modifiedAt: reader.readDateTime(offsets[4]),
|
||||
name: reader.readString(offsets[5]),
|
||||
remoteId: reader.readStringOrNull(offsets[6]),
|
||||
shared: reader.readBool(offsets[7]),
|
||||
startDate: reader.readDateTimeOrNull(offsets[8]),
|
||||
activityEnabled: reader.readBool(offsets[0]),
|
||||
createdAt: reader.readDateTime(offsets[1]),
|
||||
endDate: reader.readDateTimeOrNull(offsets[2]),
|
||||
lastModifiedAssetTimestamp: reader.readDateTimeOrNull(offsets[3]),
|
||||
localId: reader.readStringOrNull(offsets[4]),
|
||||
modifiedAt: reader.readDateTime(offsets[5]),
|
||||
name: reader.readString(offsets[6]),
|
||||
remoteId: reader.readStringOrNull(offsets[7]),
|
||||
shared: reader.readBool(offsets[8]),
|
||||
startDate: reader.readDateTimeOrNull(offsets[9]),
|
||||
);
|
||||
object.id = id;
|
||||
return object;
|
||||
|
@ -197,22 +204,24 @@ P _albumDeserializeProp<P>(
|
|||
) {
|
||||
switch (propertyId) {
|
||||
case 0:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
return (reader.readBool(offset)) as P;
|
||||
case 1:
|
||||
return (reader.readDateTimeOrNull(offset)) as P;
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 2:
|
||||
return (reader.readDateTimeOrNull(offset)) as P;
|
||||
case 3:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
return (reader.readDateTimeOrNull(offset)) as P;
|
||||
case 4:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 5:
|
||||
return (reader.readString(offset)) as P;
|
||||
case 6:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 5:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 6:
|
||||
return (reader.readString(offset)) as P;
|
||||
case 7:
|
||||
return (reader.readBool(offset)) as P;
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 8:
|
||||
return (reader.readBool(offset)) as P;
|
||||
case 9:
|
||||
return (reader.readDateTimeOrNull(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
|
@ -442,6 +451,16 @@ extension AlbumQueryWhere on QueryBuilder<Album, Album, QWhereClause> {
|
|||
}
|
||||
|
||||
extension AlbumQueryFilter on QueryBuilder<Album, Album, QFilterCondition> {
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition> activityEnabledEqualTo(
|
||||
bool value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'activityEnabled',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition> createdAtEqualTo(
|
||||
DateTime value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
|
@ -1385,6 +1404,18 @@ extension AlbumQueryLinks on QueryBuilder<Album, Album, QFilterCondition> {
|
|||
}
|
||||
|
||||
extension AlbumQuerySortBy on QueryBuilder<Album, Album, QSortBy> {
|
||||
QueryBuilder<Album, Album, QAfterSortBy> sortByActivityEnabled() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'activityEnabled', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy> sortByActivityEnabledDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'activityEnabled', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy> sortByCreatedAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'createdAt', Sort.asc);
|
||||
|
@ -1496,6 +1527,18 @@ extension AlbumQuerySortBy on QueryBuilder<Album, Album, QSortBy> {
|
|||
}
|
||||
|
||||
extension AlbumQuerySortThenBy on QueryBuilder<Album, Album, QSortThenBy> {
|
||||
QueryBuilder<Album, Album, QAfterSortBy> thenByActivityEnabled() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'activityEnabled', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy> thenByActivityEnabledDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'activityEnabled', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy> thenByCreatedAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'createdAt', Sort.asc);
|
||||
|
@ -1619,6 +1662,12 @@ extension AlbumQuerySortThenBy on QueryBuilder<Album, Album, QSortThenBy> {
|
|||
}
|
||||
|
||||
extension AlbumQueryWhereDistinct on QueryBuilder<Album, Album, QDistinct> {
|
||||
QueryBuilder<Album, Album, QDistinct> distinctByActivityEnabled() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'activityEnabled');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QDistinct> distinctByCreatedAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'createdAt');
|
||||
|
@ -1684,6 +1733,12 @@ extension AlbumQueryProperty on QueryBuilder<Album, Album, QQueryProperty> {
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, bool, QQueryOperations> activityEnabledProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'activityEnabled');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, DateTime, QQueryOperations> createdAtProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'createdAt');
|
||||
|
|
|
@ -22,6 +22,7 @@ class ApiService {
|
|||
late PersonApi personApi;
|
||||
late AuditApi auditApi;
|
||||
late SharedLinkApi sharedLinkApi;
|
||||
late ActivityApi activityApi;
|
||||
|
||||
ApiService() {
|
||||
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
|
||||
|
@ -47,6 +48,7 @@ class ApiService {
|
|||
personApi = PersonApi(_apiClient);
|
||||
auditApi = AuditApi(_apiClient);
|
||||
sharedLinkApi = SharedLinkApi(_apiClient);
|
||||
activityApi = ActivityApi(_apiClient);
|
||||
}
|
||||
|
||||
Future<String> resolveAndSetEndpoint(String serverUrl) async {
|
||||
|
|
|
@ -62,20 +62,31 @@ class AssetService {
|
|||
|
||||
/// Returns `null` if the server state did not change, else list of assets
|
||||
Future<List<Asset>?> _getRemoteAssets(User user) async {
|
||||
const int chunkSize = 5000;
|
||||
try {
|
||||
final List<AssetResponseDto>? assets =
|
||||
await _apiService.assetApi.getAllAssets(
|
||||
userId: user.id,
|
||||
);
|
||||
if (assets == null) {
|
||||
return null;
|
||||
} else if (assets.isNotEmpty && assets.first.ownerId != user.id) {
|
||||
log.warning("Make sure that server and app versions match!"
|
||||
" The server returned assets for user ${assets.first.ownerId}"
|
||||
" while requesting assets of user ${user.id}");
|
||||
return null;
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
final List<Asset> allAssets = [];
|
||||
for (int i = 0;; i += chunkSize) {
|
||||
final List<AssetResponseDto>? assets =
|
||||
await _apiService.assetApi.getAllAssets(
|
||||
userId: user.id,
|
||||
// updatedBefore is important! without it we could
|
||||
// a) get the same Asset multiple times in different versions (when
|
||||
// the asset is modified while the chunks are loaded from the server)
|
||||
// b) miss assets when new assets are inserted in between the calls
|
||||
updatedBefore: now,
|
||||
skip: i,
|
||||
take: chunkSize,
|
||||
);
|
||||
if (assets == null) {
|
||||
return null;
|
||||
}
|
||||
allAssets.addAll(assets.map(Asset.remote));
|
||||
if (assets.length < chunkSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return assets.map(Asset.remote).toList();
|
||||
return allAssets;
|
||||
} catch (error, stack) {
|
||||
log.severe(
|
||||
'Error while getting remote assets: ${error.toString()}',
|
||||
|
|
|
@ -197,7 +197,7 @@ class SyncService {
|
|||
User user,
|
||||
FutureOr<List<Asset>?> Function(User user) loadAssets,
|
||||
) async {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
final List<Asset>? remote = await loadAssets(user);
|
||||
if (remote == null) {
|
||||
return false;
|
||||
|
@ -210,6 +210,10 @@ class SyncService {
|
|||
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
|
||||
|
||||
remote.sort(Asset.compareByChecksum);
|
||||
|
||||
// filter our duplicates that might be introduced by the chunked retrieval
|
||||
remote.uniqueConsecutive(compare: Asset.compareByChecksum);
|
||||
|
||||
final (toAdd, toUpdate, toRemove) = _diffAssets(remote, inDb, remote: true);
|
||||
if (toAdd.isEmpty && toUpdate.isEmpty && toRemove.isEmpty) {
|
||||
await _updateUserAssetsETag(user, now);
|
||||
|
@ -759,6 +763,12 @@ class SyncService {
|
|||
final List<Asset> toAdd = [];
|
||||
final List<Asset> toUpdate = [];
|
||||
final List<Asset> toRemove = [];
|
||||
if (assets.isEmpty || inDb.isEmpty) {
|
||||
// fast path for trivial cases: halfes memory usage during initial sync
|
||||
return assets.isEmpty
|
||||
? (toAdd, toUpdate, inDb) // remove all from DB
|
||||
: (assets, toUpdate, toRemove); // add all assets
|
||||
}
|
||||
diffSortedListsSync(
|
||||
inDb,
|
||||
assets,
|
||||
|
|
|
@ -25,6 +25,16 @@ class UserCircleAvatar extends ConsumerWidget {
|
|||
bool isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
final profileImageUrl =
|
||||
'${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${user.id}?d=${Random().nextInt(1024)}';
|
||||
|
||||
final textIcon = Text(
|
||||
user.firstName[0].toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
),
|
||||
);
|
||||
return CircleAvatar(
|
||||
backgroundColor: user.avatarColor.toColor(isDarkTheme),
|
||||
radius: radius,
|
||||
|
@ -52,8 +62,7 @@ class UserCircleAvatar extends ConsumerWidget {
|
|||
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
|
||||
},
|
||||
fadeInDuration: const Duration(milliseconds: 300),
|
||||
errorWidget: (context, error, stackTrace) =>
|
||||
Image.memory(kTransparentImage),
|
||||
errorWidget: (context, error, stackTrace) => textIcon,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
36
mobile/lib/utils/datetime_extensions.dart
Normal file
|
@ -0,0 +1,36 @@
|
|||
extension TimeAgoExtension on DateTime {
|
||||
String timeAgo({bool numericDates = true}) {
|
||||
DateTime date = toLocal();
|
||||
final date2 = DateTime.now().toLocal();
|
||||
final difference = date2.difference(date);
|
||||
|
||||
if (difference.inSeconds < 5) {
|
||||
return 'Just now';
|
||||
} else if (difference.inSeconds < 60) {
|
||||
return '${difference.inSeconds} seconds ago';
|
||||
} else if (difference.inMinutes <= 1) {
|
||||
return (numericDates) ? '1 minute ago' : 'A minute ago';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes} minutes ago';
|
||||
} else if (difference.inHours <= 1) {
|
||||
return (numericDates) ? '1 hour ago' : 'An hour ago';
|
||||
} else if (difference.inHours < 60) {
|
||||
return '${difference.inHours} hours ago';
|
||||
} else if (difference.inDays <= 1) {
|
||||
return (numericDates) ? '1 day ago' : 'Yesterday';
|
||||
} else if (difference.inDays < 6) {
|
||||
return '${difference.inDays} days ago';
|
||||
} else if ((difference.inDays / 7).ceil() <= 1) {
|
||||
return (numericDates) ? '1 week ago' : 'Last week';
|
||||
} else if ((difference.inDays / 7).ceil() < 4) {
|
||||
return '${(difference.inDays / 7).ceil()} weeks ago';
|
||||
} else if ((difference.inDays / 30).ceil() <= 1) {
|
||||
return (numericDates) ? '1 month ago' : 'Last month';
|
||||
} else if ((difference.inDays / 30).ceil() < 30) {
|
||||
return '${(difference.inDays / 30).ceil()} months ago';
|
||||
} else if ((difference.inDays / 365).ceil() <= 1) {
|
||||
return (numericDates) ? '1 year ago' : 'Last year';
|
||||
}
|
||||
return '${(difference.inDays / 365).floor()} years ago';
|
||||
}
|
||||
}
|
1
mobile/openapi/doc/AlbumResponseDto.md
generated
|
@ -17,6 +17,7 @@ Name | Type | Description | Notes
|
|||
**endDate** | [**DateTime**](DateTime.md) | | [optional]
|
||||
**hasSharedLink** | **bool** | |
|
||||
**id** | **String** | |
|
||||
**isActivityEnabled** | **bool** | |
|
||||
**lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional]
|
||||
**owner** | [**UserResponseDto**](UserResponseDto.md) | |
|
||||
**ownerId** | **String** | |
|
||||
|
|
20
mobile/openapi/doc/AssetApi.md
generated
|
@ -374,7 +374,7 @@ void (empty response body)
|
|||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **getAllAssets**
|
||||
> List<AssetResponseDto> getAllAssets(userId, isFavorite, isArchived, skip, updatedAfter, ifNoneMatch)
|
||||
> List<AssetResponseDto> getAllAssets(skip, take, userId, isFavorite, isArchived, updatedAfter, updatedBefore, ifNoneMatch)
|
||||
|
||||
|
||||
|
||||
|
@ -399,15 +399,17 @@ import 'package:openapi/api.dart';
|
|||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = AssetApi();
|
||||
final skip = 56; // int |
|
||||
final take = 56; // int |
|
||||
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||
final isFavorite = true; // bool |
|
||||
final isArchived = true; // bool |
|
||||
final skip = 8.14; // num |
|
||||
final updatedAfter = 2013-10-20T19:20:30+01:00; // DateTime |
|
||||
final updatedBefore = 2013-10-20T19:20:30+01:00; // DateTime |
|
||||
final ifNoneMatch = ifNoneMatch_example; // String | ETag of data already cached on the client
|
||||
|
||||
try {
|
||||
final result = api_instance.getAllAssets(userId, isFavorite, isArchived, skip, updatedAfter, ifNoneMatch);
|
||||
final result = api_instance.getAllAssets(skip, take, userId, isFavorite, isArchived, updatedAfter, updatedBefore, ifNoneMatch);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling AssetApi->getAllAssets: $e\n');
|
||||
|
@ -418,11 +420,13 @@ try {
|
|||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**skip** | **int**| | [optional]
|
||||
**take** | **int**| | [optional]
|
||||
**userId** | **String**| | [optional]
|
||||
**isFavorite** | **bool**| | [optional]
|
||||
**isArchived** | **bool**| | [optional]
|
||||
**skip** | **num**| | [optional]
|
||||
**updatedAfter** | **DateTime**| | [optional]
|
||||
**updatedBefore** | **DateTime**| | [optional]
|
||||
**ifNoneMatch** | **String**| ETag of data already cached on the client | [optional]
|
||||
|
||||
### Return type
|
||||
|
@ -1696,7 +1700,7 @@ void (empty response body)
|
|||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **uploadFile**
|
||||
> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData)
|
||||
> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, duration, isArchived, isExternal, isFavorite, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData)
|
||||
|
||||
|
||||
|
||||
|
@ -1724,11 +1728,11 @@ final deviceAssetId = deviceAssetId_example; // String |
|
|||
final deviceId = deviceId_example; // String |
|
||||
final fileCreatedAt = 2013-10-20T19:20:30+01:00; // DateTime |
|
||||
final fileModifiedAt = 2013-10-20T19:20:30+01:00; // DateTime |
|
||||
final isFavorite = true; // bool |
|
||||
final key = key_example; // String |
|
||||
final duration = duration_example; // String |
|
||||
final isArchived = true; // bool |
|
||||
final isExternal = true; // bool |
|
||||
final isFavorite = true; // bool |
|
||||
final isOffline = true; // bool |
|
||||
final isReadOnly = true; // bool |
|
||||
final isVisible = true; // bool |
|
||||
|
@ -1737,7 +1741,7 @@ final livePhotoData = BINARY_DATA_HERE; // MultipartFile |
|
|||
final sidecarData = BINARY_DATA_HERE; // MultipartFile |
|
||||
|
||||
try {
|
||||
final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData);
|
||||
final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, duration, isArchived, isExternal, isFavorite, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling AssetApi->uploadFile: $e\n');
|
||||
|
@ -1753,11 +1757,11 @@ Name | Type | Description | Notes
|
|||
**deviceId** | **String**| |
|
||||
**fileCreatedAt** | **DateTime**| |
|
||||
**fileModifiedAt** | **DateTime**| |
|
||||
**isFavorite** | **bool**| |
|
||||
**key** | **String**| | [optional]
|
||||
**duration** | **String**| | [optional]
|
||||
**isArchived** | **bool**| | [optional]
|
||||
**isExternal** | **bool**| | [optional]
|
||||
**isFavorite** | **bool**| | [optional]
|
||||
**isOffline** | **bool**| | [optional]
|
||||
**isReadOnly** | **bool**| | [optional]
|
||||
**isVisible** | **bool**| | [optional]
|
||||
|
|
2
mobile/openapi/doc/ImportAssetDto.md
generated
|
@ -16,7 +16,7 @@ Name | Type | Description | Notes
|
|||
**fileModifiedAt** | [**DateTime**](DateTime.md) | |
|
||||
**isArchived** | **bool** | | [optional]
|
||||
**isExternal** | **bool** | | [optional]
|
||||
**isFavorite** | **bool** | |
|
||||
**isFavorite** | **bool** | | [optional]
|
||||
**isOffline** | **bool** | | [optional]
|
||||
**isReadOnly** | **bool** | | [optional] [default to true]
|
||||
**isVisible** | **bool** | | [optional]
|
||||
|
|
1
mobile/openapi/doc/UpdateAlbumDto.md
generated
|
@ -11,6 +11,7 @@ Name | Type | Description | Notes
|
|||
**albumName** | **String** | | [optional]
|
||||
**albumThumbnailAssetId** | **String** | | [optional]
|
||||
**description** | **String** | | [optional]
|
||||
**isActivityEnabled** | **bool** | | [optional]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
|
48
mobile/openapi/lib/api/asset_api.dart
generated
|
@ -309,19 +309,23 @@ class AssetApi {
|
|||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [int] skip:
|
||||
///
|
||||
/// * [int] take:
|
||||
///
|
||||
/// * [String] userId:
|
||||
///
|
||||
/// * [bool] isFavorite:
|
||||
///
|
||||
/// * [bool] isArchived:
|
||||
///
|
||||
/// * [num] skip:
|
||||
///
|
||||
/// * [DateTime] updatedAfter:
|
||||
///
|
||||
/// * [DateTime] updatedBefore:
|
||||
///
|
||||
/// * [String] ifNoneMatch:
|
||||
/// ETag of data already cached on the client
|
||||
Future<Response> getAllAssetsWithHttpInfo({ String? userId, bool? isFavorite, bool? isArchived, num? skip, DateTime? updatedAfter, String? ifNoneMatch, }) async {
|
||||
Future<Response> getAllAssetsWithHttpInfo({ int? skip, int? take, String? userId, bool? isFavorite, bool? isArchived, DateTime? updatedAfter, DateTime? updatedBefore, String? ifNoneMatch, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset';
|
||||
|
||||
|
@ -332,6 +336,12 @@ class AssetApi {
|
|||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (skip != null) {
|
||||
queryParams.addAll(_queryParams('', 'skip', skip));
|
||||
}
|
||||
if (take != null) {
|
||||
queryParams.addAll(_queryParams('', 'take', take));
|
||||
}
|
||||
if (userId != null) {
|
||||
queryParams.addAll(_queryParams('', 'userId', userId));
|
||||
}
|
||||
|
@ -341,12 +351,12 @@ class AssetApi {
|
|||
if (isArchived != null) {
|
||||
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
|
||||
}
|
||||
if (skip != null) {
|
||||
queryParams.addAll(_queryParams('', 'skip', skip));
|
||||
}
|
||||
if (updatedAfter != null) {
|
||||
queryParams.addAll(_queryParams('', 'updatedAfter', updatedAfter));
|
||||
}
|
||||
if (updatedBefore != null) {
|
||||
queryParams.addAll(_queryParams('', 'updatedBefore', updatedBefore));
|
||||
}
|
||||
|
||||
if (ifNoneMatch != null) {
|
||||
headerParams[r'if-none-match'] = parameterToString(ifNoneMatch);
|
||||
|
@ -370,20 +380,24 @@ class AssetApi {
|
|||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [int] skip:
|
||||
///
|
||||
/// * [int] take:
|
||||
///
|
||||
/// * [String] userId:
|
||||
///
|
||||
/// * [bool] isFavorite:
|
||||
///
|
||||
/// * [bool] isArchived:
|
||||
///
|
||||
/// * [num] skip:
|
||||
///
|
||||
/// * [DateTime] updatedAfter:
|
||||
///
|
||||
/// * [DateTime] updatedBefore:
|
||||
///
|
||||
/// * [String] ifNoneMatch:
|
||||
/// ETag of data already cached on the client
|
||||
Future<List<AssetResponseDto>?> getAllAssets({ String? userId, bool? isFavorite, bool? isArchived, num? skip, DateTime? updatedAfter, String? ifNoneMatch, }) async {
|
||||
final response = await getAllAssetsWithHttpInfo( userId: userId, isFavorite: isFavorite, isArchived: isArchived, skip: skip, updatedAfter: updatedAfter, ifNoneMatch: ifNoneMatch, );
|
||||
Future<List<AssetResponseDto>?> getAllAssets({ int? skip, int? take, String? userId, bool? isFavorite, bool? isArchived, DateTime? updatedAfter, DateTime? updatedBefore, String? ifNoneMatch, }) async {
|
||||
final response = await getAllAssetsWithHttpInfo( skip: skip, take: take, userId: userId, isFavorite: isFavorite, isArchived: isArchived, updatedAfter: updatedAfter, updatedBefore: updatedBefore, ifNoneMatch: ifNoneMatch, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
@ -1660,8 +1674,6 @@ class AssetApi {
|
|||
///
|
||||
/// * [DateTime] fileModifiedAt (required):
|
||||
///
|
||||
/// * [bool] isFavorite (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] duration:
|
||||
|
@ -1670,6 +1682,8 @@ class AssetApi {
|
|||
///
|
||||
/// * [bool] isExternal:
|
||||
///
|
||||
/// * [bool] isFavorite:
|
||||
///
|
||||
/// * [bool] isOffline:
|
||||
///
|
||||
/// * [bool] isReadOnly:
|
||||
|
@ -1681,7 +1695,7 @@ class AssetApi {
|
|||
/// * [MultipartFile] livePhotoData:
|
||||
///
|
||||
/// * [MultipartFile] sidecarData:
|
||||
Future<Response> uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
|
||||
Future<Response> uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isFavorite, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/upload';
|
||||
|
||||
|
@ -1790,8 +1804,6 @@ class AssetApi {
|
|||
///
|
||||
/// * [DateTime] fileModifiedAt (required):
|
||||
///
|
||||
/// * [bool] isFavorite (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] duration:
|
||||
|
@ -1800,6 +1812,8 @@ class AssetApi {
|
|||
///
|
||||
/// * [bool] isExternal:
|
||||
///
|
||||
/// * [bool] isFavorite:
|
||||
///
|
||||
/// * [bool] isOffline:
|
||||
///
|
||||
/// * [bool] isReadOnly:
|
||||
|
@ -1811,8 +1825,8 @@ class AssetApi {
|
|||
/// * [MultipartFile] livePhotoData:
|
||||
///
|
||||
/// * [MultipartFile] sidecarData:
|
||||
Future<AssetFileUploadResponseDto?> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
|
||||
final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key: key, duration: duration, isArchived: isArchived, isExternal: isExternal, isOffline: isOffline, isReadOnly: isReadOnly, isVisible: isVisible, libraryId: libraryId, livePhotoData: livePhotoData, sidecarData: sidecarData, );
|
||||
Future<AssetFileUploadResponseDto?> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isFavorite, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
|
||||
final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, duration: duration, isArchived: isArchived, isExternal: isExternal, isFavorite: isFavorite, isOffline: isOffline, isReadOnly: isReadOnly, isVisible: isVisible, libraryId: libraryId, livePhotoData: livePhotoData, sidecarData: sidecarData, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
|
10
mobile/openapi/lib/model/album_response_dto.dart
generated
|
@ -22,6 +22,7 @@ class AlbumResponseDto {
|
|||
this.endDate,
|
||||
required this.hasSharedLink,
|
||||
required this.id,
|
||||
required this.isActivityEnabled,
|
||||
this.lastModifiedAssetTimestamp,
|
||||
required this.owner,
|
||||
required this.ownerId,
|
||||
|
@ -55,6 +56,8 @@ class AlbumResponseDto {
|
|||
|
||||
String id;
|
||||
|
||||
bool isActivityEnabled;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
|
@ -92,6 +95,7 @@ class AlbumResponseDto {
|
|||
other.endDate == endDate &&
|
||||
other.hasSharedLink == hasSharedLink &&
|
||||
other.id == id &&
|
||||
other.isActivityEnabled == isActivityEnabled &&
|
||||
other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp &&
|
||||
other.owner == owner &&
|
||||
other.ownerId == ownerId &&
|
||||
|
@ -112,6 +116,7 @@ class AlbumResponseDto {
|
|||
(endDate == null ? 0 : endDate!.hashCode) +
|
||||
(hasSharedLink.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isActivityEnabled.hashCode) +
|
||||
(lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) +
|
||||
(owner.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
|
@ -121,7 +126,7 @@ class AlbumResponseDto {
|
|||
(updatedAt.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]';
|
||||
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
|
@ -142,6 +147,7 @@ class AlbumResponseDto {
|
|||
}
|
||||
json[r'hasSharedLink'] = this.hasSharedLink;
|
||||
json[r'id'] = this.id;
|
||||
json[r'isActivityEnabled'] = this.isActivityEnabled;
|
||||
if (this.lastModifiedAssetTimestamp != null) {
|
||||
json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
|
||||
} else {
|
||||
|
@ -177,6 +183,7 @@ class AlbumResponseDto {
|
|||
endDate: mapDateTime(json, r'endDate', ''),
|
||||
hasSharedLink: mapValueOfType<bool>(json, r'hasSharedLink')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
|
||||
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', ''),
|
||||
owner: UserResponseDto.fromJson(json[r'owner'])!,
|
||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||
|
@ -239,6 +246,7 @@ class AlbumResponseDto {
|
|||
'description',
|
||||
'hasSharedLink',
|
||||
'id',
|
||||
'isActivityEnabled',
|
||||
'owner',
|
||||
'ownerId',
|
||||
'shared',
|
||||
|
|
19
mobile/openapi/lib/model/import_asset_dto.dart
generated
|
@ -21,7 +21,7 @@ class ImportAssetDto {
|
|||
required this.fileModifiedAt,
|
||||
this.isArchived,
|
||||
this.isExternal,
|
||||
required this.isFavorite,
|
||||
this.isFavorite,
|
||||
this.isOffline,
|
||||
this.isReadOnly = true,
|
||||
this.isVisible,
|
||||
|
@ -63,7 +63,13 @@ class ImportAssetDto {
|
|||
///
|
||||
bool? isExternal;
|
||||
|
||||
bool isFavorite;
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
bool? isFavorite;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
|
@ -127,7 +133,7 @@ class ImportAssetDto {
|
|||
(fileModifiedAt.hashCode) +
|
||||
(isArchived == null ? 0 : isArchived!.hashCode) +
|
||||
(isExternal == null ? 0 : isExternal!.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
(isOffline == null ? 0 : isOffline!.hashCode) +
|
||||
(isReadOnly.hashCode) +
|
||||
(isVisible == null ? 0 : isVisible!.hashCode) +
|
||||
|
@ -159,7 +165,11 @@ class ImportAssetDto {
|
|||
} else {
|
||||
// json[r'isExternal'] = null;
|
||||
}
|
||||
if (this.isFavorite != null) {
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
} else {
|
||||
// json[r'isFavorite'] = null;
|
||||
}
|
||||
if (this.isOffline != null) {
|
||||
json[r'isOffline'] = this.isOffline;
|
||||
} else {
|
||||
|
@ -200,7 +210,7 @@ class ImportAssetDto {
|
|||
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!,
|
||||
isArchived: mapValueOfType<bool>(json, r'isArchived'),
|
||||
isExternal: mapValueOfType<bool>(json, r'isExternal'),
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
isOffline: mapValueOfType<bool>(json, r'isOffline'),
|
||||
isReadOnly: mapValueOfType<bool>(json, r'isReadOnly') ?? true,
|
||||
isVisible: mapValueOfType<bool>(json, r'isVisible'),
|
||||
|
@ -258,7 +268,6 @@ class ImportAssetDto {
|
|||
'deviceId',
|
||||
'fileCreatedAt',
|
||||
'fileModifiedAt',
|
||||
'isFavorite',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
23
mobile/openapi/lib/model/update_album_dto.dart
generated
|
@ -16,6 +16,7 @@ class UpdateAlbumDto {
|
|||
this.albumName,
|
||||
this.albumThumbnailAssetId,
|
||||
this.description,
|
||||
this.isActivityEnabled,
|
||||
});
|
||||
|
||||
///
|
||||
|
@ -42,21 +43,31 @@ class UpdateAlbumDto {
|
|||
///
|
||||
String? description;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
bool? isActivityEnabled;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto &&
|
||||
other.albumName == albumName &&
|
||||
other.albumThumbnailAssetId == albumThumbnailAssetId &&
|
||||
other.description == description;
|
||||
other.description == description &&
|
||||
other.isActivityEnabled == isActivityEnabled;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(albumName == null ? 0 : albumName!.hashCode) +
|
||||
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
|
||||
(description == null ? 0 : description!.hashCode);
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(isActivityEnabled == null ? 0 : isActivityEnabled!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description]';
|
||||
String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description, isActivityEnabled=$isActivityEnabled]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
|
@ -75,6 +86,11 @@ class UpdateAlbumDto {
|
|||
} else {
|
||||
// json[r'description'] = null;
|
||||
}
|
||||
if (this.isActivityEnabled != null) {
|
||||
json[r'isActivityEnabled'] = this.isActivityEnabled;
|
||||
} else {
|
||||
// json[r'isActivityEnabled'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
|
@ -89,6 +105,7 @@ class UpdateAlbumDto {
|
|||
albumName: mapValueOfType<String>(json, r'albumName'),
|
||||
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
5
mobile/openapi/test/album_response_dto_test.dart
generated
|
@ -61,6 +61,11 @@ void main() {
|
|||
// TODO
|
||||
});
|
||||
|
||||
// bool isActivityEnabled
|
||||
test('to test the property `isActivityEnabled`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// DateTime lastModifiedAssetTimestamp
|
||||
test('to test the property `lastModifiedAssetTimestamp`', () async {
|
||||
// TODO
|
||||
|
|
4
mobile/openapi/test/asset_api_test.dart
generated
|
@ -53,7 +53,7 @@ void main() {
|
|||
|
||||
// Get all AssetEntity belong to the user
|
||||
//
|
||||
//Future<List<AssetResponseDto>> getAllAssets({ String userId, bool isFavorite, bool isArchived, num skip, DateTime updatedAfter, String ifNoneMatch }) async
|
||||
//Future<List<AssetResponseDto>> getAllAssets({ int skip, int take, String userId, bool isFavorite, bool isArchived, DateTime updatedAfter, DateTime updatedBefore, String ifNoneMatch }) async
|
||||
test('test getAllAssets', () async {
|
||||
// TODO
|
||||
});
|
||||
|
@ -172,7 +172,7 @@ void main() {
|
|||
// TODO
|
||||
});
|
||||
|
||||
//Future<AssetFileUploadResponseDto> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isExternal, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async
|
||||
//Future<AssetFileUploadResponseDto> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String key, String duration, bool isArchived, bool isExternal, bool isFavorite, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async
|
||||
test('test uploadFile', () async {
|
||||
// TODO
|
||||
});
|
||||
|
|
5
mobile/openapi/test/update_album_dto_test.dart
generated
|
@ -31,6 +31,11 @@ void main() {
|
|||
// TODO
|
||||
});
|
||||
|
||||
// bool isActivityEnabled
|
||||
test('to test the property `isActivityEnabled`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ name: immich_mobile
|
|||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.84.0+108
|
||||
version: 1.85.0+109
|
||||
isar_version: &isar_version 3.1.0+1
|
||||
|
||||
environment:
|
||||
|
|
|
@ -914,6 +914,22 @@
|
|||
"description": "Get all AssetEntity belong to the user",
|
||||
"operationId": "getAllAssets",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "userId",
|
||||
"required": false,
|
||||
|
@ -940,15 +956,16 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"name": "skip",
|
||||
"name": "updatedAfter",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "updatedAfter",
|
||||
"name": "updatedBefore",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
|
@ -5644,7 +5661,7 @@
|
|||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.84.0",
|
||||
"version": "1.85.0",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
|
@ -5900,6 +5917,9 @@
|
|||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"isActivityEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"lastModifiedAssetTimestamp": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
|
@ -5941,7 +5961,8 @@
|
|||
"sharedUsers",
|
||||
"hasSharedLink",
|
||||
"assets",
|
||||
"owner"
|
||||
"owner",
|
||||
"isActivityEnabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
@ -6678,8 +6699,7 @@
|
|||
"deviceAssetId",
|
||||
"deviceId",
|
||||
"fileCreatedAt",
|
||||
"fileModifiedAt",
|
||||
"isFavorite"
|
||||
"fileModifiedAt"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
@ -7166,8 +7186,7 @@
|
|||
"deviceAssetId",
|
||||
"deviceId",
|
||||
"fileCreatedAt",
|
||||
"fileModifiedAt",
|
||||
"isFavorite"
|
||||
"fileModifiedAt"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
@ -8918,6 +8937,9 @@
|
|||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"isActivityEnabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
|
4
server/package-lock.json
generated
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "immich",
|
||||
"version": "1.84.0",
|
||||
"version": "1.85.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich",
|
||||
"version": "1.84.0",
|
||||
"version": "1.85.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.22.11",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "immich",
|
||||
"version": "1.84.0",
|
||||
"version": "1.85.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
|
|
@ -138,10 +138,7 @@ export class AccessCore {
|
|||
switch (permission) {
|
||||
// uses album id
|
||||
case Permission.ACTIVITY_CREATE:
|
||||
return (
|
||||
(await this.repository.album.hasOwnerAccess(authUser.id, id)) ||
|
||||
(await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
|
||||
);
|
||||
return await this.repository.activity.hasCreateAccess(authUser.id, id);
|
||||
|
||||
// uses activity id
|
||||
case Permission.ACTIVITY_DELETE:
|
||||
|
|
|
@ -58,6 +58,7 @@ export class ActivityService {
|
|||
delete dto.comment;
|
||||
[activity] = await this.repository.search({
|
||||
...common,
|
||||
isGlobal: !dto.assetId,
|
||||
isLiked: true,
|
||||
});
|
||||
duplicate = !!activity;
|
||||
|
|
|
@ -94,7 +94,7 @@ describe(ActivityService.name, () => {
|
|||
});
|
||||
|
||||
it('should create a comment', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
|
||||
activityMock.create.mockResolvedValue(activityStub.oneComment);
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
|
@ -113,8 +113,23 @@ describe(ActivityService.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should create a like', async () => {
|
||||
it('should fail because activity is disabled for the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.activity.hasCreateAccess.mockResolvedValue(false);
|
||||
activityMock.create.mockResolvedValue(activityStub.oneComment);
|
||||
|
||||
await expect(
|
||||
sut.create(authStub.admin, {
|
||||
albumId: 'album-id',
|
||||
assetId: 'asset-id',
|
||||
type: ReactionType.COMMENT,
|
||||
comment: 'comment',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create a like', async () => {
|
||||
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
|
||||
activityMock.create.mockResolvedValue(activityStub.liked);
|
||||
activityMock.search.mockResolvedValue([]);
|
||||
|
||||
|
@ -134,6 +149,7 @@ describe(ActivityService.name, () => {
|
|||
|
||||
it('should skip if like exists', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
|
||||
activityMock.search.mockResolvedValue([activityStub.liked]);
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
|
|
|
@ -21,6 +21,7 @@ export class AlbumResponseDto {
|
|||
lastModifiedAssetTimestamp?: Date;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
isActivityEnabled!: boolean;
|
||||
}
|
||||
|
||||
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => {
|
||||
|
@ -61,6 +62,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumRespons
|
|||
endDate,
|
||||
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset)),
|
||||
assetCount: entity.assets?.length || 0,
|
||||
isActivityEnabled: entity.isActivityEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -125,12 +125,12 @@ export class AlbumService {
|
|||
throw new BadRequestException('Invalid album thumbnail');
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAlbum = await this.albumRepository.update({
|
||||
id: album.id,
|
||||
albumName: dto.albumName,
|
||||
description: dto.description,
|
||||
albumThumbnailAssetId: dto.albumThumbnailAssetId,
|
||||
isActivityEnabled: dto.isActivityEnabled,
|
||||
});
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [updatedAlbum.id] } });
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { IsString } from 'class-validator';
|
||||
import { IsBoolean, IsString } from 'class-validator';
|
||||
import { Optional, ValidateUUID } from '../../domain.util';
|
||||
|
||||
export class UpdateAlbumDto {
|
||||
|
@ -12,4 +12,8 @@ export class UpdateAlbumDto {
|
|||
|
||||
@ValidateUUID({ optional: true })
|
||||
albumThumbnailAssetId?: string;
|
||||
|
||||
@Optional()
|
||||
@IsBoolean()
|
||||
isActivityEnabled?: boolean;
|
||||
}
|
||||
|
|
|
@ -216,7 +216,13 @@ export class PersonService {
|
|||
return true;
|
||||
}
|
||||
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
const relations = {
|
||||
exifInfo: true,
|
||||
faces: {
|
||||
person: true,
|
||||
},
|
||||
};
|
||||
const [asset] = await this.assetRepository.getByIds([id], relations);
|
||||
if (!asset || !asset.resizePath || asset.faces?.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -2,8 +2,9 @@ export const IAccessRepository = 'IAccessRepository';
|
|||
|
||||
export interface IAccessRepository {
|
||||
activity: {
|
||||
hasOwnerAccess(userId: string, albumId: string): Promise<boolean>;
|
||||
hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean>;
|
||||
hasOwnerAccess(userId: string, activityId: string): Promise<boolean>;
|
||||
hasAlbumOwnerAccess(userId: string, activityId: string): Promise<boolean>;
|
||||
hasCreateAccess(userId: string, albumId: string): Promise<boolean>;
|
||||
};
|
||||
asset: {
|
||||
hasOwnerAccess(userId: string, assetId: string): Promise<boolean>;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
||||
import { FindOptionsRelations } from 'typeorm';
|
||||
import { Paginated, PaginationOptions } from '../domain.util';
|
||||
|
||||
export type AssetStats = Record<AssetType, number>;
|
||||
|
@ -99,7 +100,7 @@ export const IAssetRepository = 'IAssetRepository';
|
|||
export interface IAssetRepository {
|
||||
create(asset: AssetCreate): Promise<AssetEntity>;
|
||||
getByDate(ownerId: string, date: Date): Promise<AssetEntity[]>;
|
||||
getByIds(ids: string[]): Promise<AssetEntity[]>;
|
||||
getByIds(ids: string[], relations?: FindOptionsRelations<AssetEntity>): Promise<AssetEntity[]>;
|
||||
getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise<AssetEntity[]>;
|
||||
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
|
||||
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { AssetCreate } from '@app/domain';
|
||||
import { AssetEntity } from '@app/infra/entities';
|
||||
import OptionalBetween from '@app/infra/utils/optional-between.util';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThan } from 'typeorm';
|
||||
import { In } from 'typeorm/find-options/operator/In';
|
||||
import { Repository } from 'typeorm/repository/Repository';
|
||||
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||
|
@ -129,7 +129,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
isVisible: true,
|
||||
isFavorite: dto.isFavorite,
|
||||
isArchived: dto.isArchived,
|
||||
updatedAt: dto.updatedAfter ? MoreThan(dto.updatedAfter) : undefined,
|
||||
updatedAt: OptionalBetween(dto.updatedAfter, dto.updatedBefore),
|
||||
},
|
||||
relations: {
|
||||
exifInfo: true,
|
||||
|
@ -137,6 +137,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
stack: true,
|
||||
},
|
||||
skip: dto.skip || 0,
|
||||
take: dto.take,
|
||||
order: {
|
||||
fileCreatedAt: 'DESC',
|
||||
},
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Optional, toBoolean } from '@app/domain';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsDate, IsNotEmpty, IsNumber, IsUUID } from 'class-validator';
|
||||
import { IsBoolean, IsDate, IsInt, IsNotEmpty, IsUUID } from 'class-validator';
|
||||
|
||||
export class AssetSearchDto {
|
||||
@Optional()
|
||||
|
@ -17,9 +17,17 @@ export class AssetSearchDto {
|
|||
isArchived?: boolean;
|
||||
|
||||
@Optional()
|
||||
@IsNumber()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
skip?: number;
|
||||
|
||||
@Optional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
take?: number;
|
||||
|
||||
@Optional()
|
||||
@IsUUID('4')
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
|
@ -29,4 +37,9 @@ export class AssetSearchDto {
|
|||
@IsDate()
|
||||
@Type(() => Date)
|
||||
updatedAfter?: Date;
|
||||
|
||||
@Optional()
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
updatedBefore?: Date;
|
||||
}
|
||||
|
|
|
@ -22,9 +22,10 @@ export class CreateAssetBase {
|
|||
@Type(() => Date)
|
||||
fileModifiedAt!: Date;
|
||||
|
||||
@Optional()
|
||||
@IsBoolean()
|
||||
@Transform(toBoolean)
|
||||
isFavorite!: boolean;
|
||||
isFavorite?: boolean;
|
||||
|
||||
@Optional()
|
||||
@IsBoolean()
|
||||
|
|
|
@ -56,4 +56,7 @@ export class AlbumEntity {
|
|||
|
||||
@OneToMany(() => SharedLinkEntity, (link) => link.album)
|
||||
sharedLinks!: SharedLinkEntity[];
|
||||
|
||||
@Column({ default: true })
|
||||
isActivityEnabled!: boolean;
|
||||
}
|
||||
|
|
14
server/src/infra/migrations/1699268680508-DisableActivity.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class DisableActivity1699268680508 implements MigrationInterface {
|
||||
name = 'DisableActivity1699268680508'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "albums" ADD "isActivityEnabled" boolean NOT NULL DEFAULT true`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "isActivityEnabled"`);
|
||||
}
|
||||
|
||||
}
|
|
@ -43,6 +43,24 @@ export class AccessRepository implements IAccessRepository {
|
|||
},
|
||||
});
|
||||
},
|
||||
hasCreateAccess: (userId: string, albumId: string): Promise<boolean> => {
|
||||
return this.albumRepository.exist({
|
||||
where: [
|
||||
{
|
||||
id: albumId,
|
||||
isActivityEnabled: true,
|
||||
sharedUsers: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: albumId,
|
||||
isActivityEnabled: true,
|
||||
ownerId: userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
library = {
|
||||
hasOwnerAccess: (userId: string, libraryId: string): Promise<boolean> => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { IActivityRepository } from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
import { ActivityEntity } from '../entities/activity.entity';
|
||||
|
||||
export interface ActivitySearch {
|
||||
|
@ -9,6 +9,7 @@ export interface ActivitySearch {
|
|||
assetId?: string;
|
||||
userId?: string;
|
||||
isLiked?: boolean;
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
@ -16,11 +17,11 @@ export class ActivityRepository implements IActivityRepository {
|
|||
constructor(@InjectRepository(ActivityEntity) private repository: Repository<ActivityEntity>) {}
|
||||
|
||||
search(options: ActivitySearch): Promise<ActivityEntity[]> {
|
||||
const { userId, assetId, albumId, isLiked } = options;
|
||||
const { userId, assetId, albumId, isLiked, isGlobal } = options;
|
||||
return this.repository.find({
|
||||
where: {
|
||||
userId,
|
||||
assetId,
|
||||
assetId: isGlobal ? IsNull() : assetId,
|
||||
albumId,
|
||||
isLiked,
|
||||
},
|
||||
|
|
|
@ -104,10 +104,9 @@ export class AssetRepository implements IAssetRepository {
|
|||
.getMany();
|
||||
}
|
||||
|
||||
getByIds(ids: string[]): Promise<AssetEntity[]> {
|
||||
return this.repository.find({
|
||||
where: { id: In(ids) },
|
||||
relations: {
|
||||
getByIds(ids: string[], relations?: FindOptionsRelations<AssetEntity>): Promise<AssetEntity[]> {
|
||||
if (!relations) {
|
||||
relations = {
|
||||
exifInfo: true,
|
||||
smartInfo: true,
|
||||
tags: true,
|
||||
|
@ -115,7 +114,11 @@ export class AssetRepository implements IAssetRepository {
|
|||
person: true,
|
||||
},
|
||||
stack: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
return this.repository.find({
|
||||
where: { id: In(ids) },
|
||||
relations,
|
||||
withDeleted: true,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -226,6 +226,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
|
|||
assets: [],
|
||||
assetCount: 0,
|
||||
owner: expect.objectContaining({ email: user1.userEmail }),
|
||||
isActivityEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -146,7 +146,6 @@ describe(`${AssetController.name} (e2e)`, () => {
|
|||
{ should: 'require `deviceId`', dto: { ...makeUploadDto({ omit: 'deviceId' }) } },
|
||||
{ should: 'require `fileCreatedAt`', dto: { ...makeUploadDto({ omit: 'fileCreatedAt' }) } },
|
||||
{ should: 'require `fileModifiedAt`', dto: { ...makeUploadDto({ omit: 'fileModifiedAt' }) } },
|
||||
{ should: 'require `isFavorite`', dto: { ...makeUploadDto({ omit: 'isFavorite' }) } },
|
||||
{ should: 'require `duration`', dto: { ...makeUploadDto({ omit: 'duration' }) } },
|
||||
{ should: 'throw if `isFavorite` is not a boolean', dto: { ...makeUploadDto(), isFavorite: 'not-a-boolean' } },
|
||||
{ should: 'throw if `isVisible` is not a boolean', dto: { ...makeUploadDto(), isVisible: 'not-a-boolean' } },
|
||||
|
|
10
server/test/fixtures/album.stub.ts
vendored
|
@ -18,6 +18,7 @@ export const albumStub = {
|
|||
deletedAt: null,
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
}),
|
||||
sharedWithUser: Object.freeze<AlbumEntity>({
|
||||
id: 'album-2',
|
||||
|
@ -33,6 +34,7 @@ export const albumStub = {
|
|||
deletedAt: null,
|
||||
sharedLinks: [],
|
||||
sharedUsers: [userStub.user1],
|
||||
isActivityEnabled: true,
|
||||
}),
|
||||
sharedWithMultiple: Object.freeze<AlbumEntity>({
|
||||
id: 'album-3',
|
||||
|
@ -48,6 +50,7 @@ export const albumStub = {
|
|||
deletedAt: null,
|
||||
sharedLinks: [],
|
||||
sharedUsers: [userStub.user1, userStub.user2],
|
||||
isActivityEnabled: true,
|
||||
}),
|
||||
sharedWithAdmin: Object.freeze<AlbumEntity>({
|
||||
id: 'album-3',
|
||||
|
@ -63,6 +66,7 @@ export const albumStub = {
|
|||
deletedAt: null,
|
||||
sharedLinks: [],
|
||||
sharedUsers: [userStub.admin],
|
||||
isActivityEnabled: true,
|
||||
}),
|
||||
oneAsset: Object.freeze<AlbumEntity>({
|
||||
id: 'album-4',
|
||||
|
@ -78,6 +82,7 @@ export const albumStub = {
|
|||
deletedAt: null,
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
}),
|
||||
twoAssets: Object.freeze<AlbumEntity>({
|
||||
id: 'album-4a',
|
||||
|
@ -93,6 +98,7 @@ export const albumStub = {
|
|||
deletedAt: null,
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
}),
|
||||
emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
|
||||
id: 'album-5',
|
||||
|
@ -108,6 +114,7 @@ export const albumStub = {
|
|||
deletedAt: null,
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
}),
|
||||
emptyWithValidThumbnail: Object.freeze<AlbumEntity>({
|
||||
id: 'album-5',
|
||||
|
@ -123,6 +130,7 @@ export const albumStub = {
|
|||
deletedAt: null,
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
}),
|
||||
oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({
|
||||
id: 'album-6',
|
||||
|
@ -138,6 +146,7 @@ export const albumStub = {
|
|||
deletedAt: null,
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
}),
|
||||
oneAssetValidThumbnail: Object.freeze<AlbumEntity>({
|
||||
id: 'album-6',
|
||||
|
@ -153,5 +162,6 @@ export const albumStub = {
|
|||
deletedAt: null,
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
isActivityEnabled: true,
|
||||
}),
|
||||
};
|
||||
|
|
2
server/test/fixtures/shared-link.stub.ts
vendored
|
@ -100,6 +100,7 @@ const albumResponse: AlbumResponseDto = {
|
|||
hasSharedLink: false,
|
||||
assets: [],
|
||||
assetCount: 1,
|
||||
isActivityEnabled: true,
|
||||
};
|
||||
|
||||
export const sharedLinkStub = {
|
||||
|
@ -179,6 +180,7 @@ export const sharedLinkStub = {
|
|||
albumThumbnailAssetId: null,
|
||||
sharedUsers: [],
|
||||
sharedLinks: [],
|
||||
isActivityEnabled: true,
|
||||
assets: [
|
||||
{
|
||||
id: 'id_1',
|
||||
|
|
|
@ -19,6 +19,7 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
|
|||
activity: {
|
||||
hasOwnerAccess: jest.fn(),
|
||||
hasAlbumOwnerAccess: jest.fn(),
|
||||
hasCreateAccess: jest.fn(),
|
||||
},
|
||||
asset: {
|
||||
hasOwnerAccess: jest.fn(),
|
||||
|
|
2
web/src/api/open-api/base.ts
generated
|
@ -4,7 +4,7 @@
|
|||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.84.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
2
web/src/api/open-api/common.ts
generated
|
@ -4,7 +4,7 @@
|
|||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.84.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
2
web/src/api/open-api/configuration.ts
generated
|
@ -4,7 +4,7 @@
|
|||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.84.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
2
web/src/api/open-api/index.ts
generated
|
@ -4,7 +4,7 @@
|
|||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.84.0
|
||||
* The version of the OpenAPI document: 1.85.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
76
web/src/lib/components/album-page/album-options.svelte
Normal file
|
@ -0,0 +1,76 @@
|
|||
<script lang="ts">
|
||||
import { mdiClose, mdiPlus } from '@mdi/js';
|
||||
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
|
||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { AlbumResponseDto, UserResponseDto } from '../../../api/open-api';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
export let user: UserResponseDto;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
close: void;
|
||||
toggleEnableActivity: void;
|
||||
showSelectSharedUser: void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<FullScreenModal on:clickOutside={() => dispatch('close')}>
|
||||
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden p-2 md:p-0">
|
||||
<div
|
||||
class="w-[550px] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
>
|
||||
<div class="px-2 pt-2">
|
||||
<div class="flex items-center">
|
||||
<h1 class="px-4 w-full self-center font-medium text-immich-primary dark:text-immich-dark-primary">Options</h1>
|
||||
<div>
|
||||
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" items-center justify-center p-4">
|
||||
<div class="py-2">
|
||||
<h2 class="text-gray text-sm mb-3">SHARING</h2>
|
||||
<div class="p-2">
|
||||
<SettingSwitch
|
||||
title="Comments & likes"
|
||||
subtitle="Let others respond"
|
||||
checked={album.isActivityEnabled}
|
||||
on:toggle={() => dispatch('toggleEnableActivity')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div class="text-gray text-sm mb-3">PEOPLE</div>
|
||||
<div class="p-2">
|
||||
<button class="flex items-center gap-2" on:click={() => dispatch('showSelectSharedUser')}>
|
||||
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
|
||||
<div><Icon path={mdiPlus} size="25" /></div>
|
||||
</div>
|
||||
<div>Invite People</div>
|
||||
</button>
|
||||
<div class="flex items-center gap-2 py-2 mt-2">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" />
|
||||
</div>
|
||||
<div class="w-full">{`${user.firstName} ${user.lastName}`}</div>
|
||||
<div>Owner</div>
|
||||
</div>
|
||||
{#each album.sharedUsers as user (user.id)}
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" />
|
||||
</div>
|
||||
<div class="w-full">{`${user.firstName} ${user.lastName}`}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenModal>
|
|
@ -7,6 +7,7 @@
|
|||
export let isLiked: ActivityResponseDto | null;
|
||||
export let numberOfComments: number | undefined;
|
||||
export let isShowActivity: boolean | undefined;
|
||||
export let disabled: boolean;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
@ -14,7 +15,7 @@
|
|||
<div
|
||||
class="w-full h-14 flex p-4 text-white items-center justify-center rounded-full gap-4 bg-immich-dark-bg bg-opacity-60"
|
||||
>
|
||||
<button on:click={() => dispatch('favorite')}>
|
||||
<button class={disabled ? 'cursor-not-allowed' : ''} on:click={() => dispatch('favorite')} {disabled}>
|
||||
<!-- svelte-ignore missing-declaration -->
|
||||
<div class="items-center justify-center">
|
||||
<Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />
|
||||
|
|
|
@ -38,6 +38,7 @@
|
|||
export let albumId: string;
|
||||
export let assetType: AssetTypeEnum | undefined = undefined;
|
||||
export let albumOwnerId: string;
|
||||
export let disabled: boolean;
|
||||
|
||||
let textArea: HTMLTextAreaElement;
|
||||
let innerHeight: number;
|
||||
|
@ -280,12 +281,15 @@
|
|||
<form class="flex w-full max-h-56 gap-1" on:submit|preventDefault={() => handleSendComment()}>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<textarea
|
||||
{disabled}
|
||||
bind:this={textArea}
|
||||
bind:value={message}
|
||||
placeholder="Say something"
|
||||
placeholder={disabled ? 'Comments are disabled' : 'Say something'}
|
||||
on:input={autoGrow}
|
||||
on:keypress={handleEnter}
|
||||
class="h-[18px] w-full max-h-56 pr-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
|
||||
class="h-[18px] {disabled
|
||||
? 'cursor-not-allowed'
|
||||
: ''} w-full max-h-56 pr-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
|
||||
/>
|
||||
</div>
|
||||
{#if isSendingMessage}
|
||||
|
|
|
@ -104,6 +104,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (album && !album.isActivityEnabled && numberOfComments === 0) {
|
||||
isShowActivity = false;
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddComment = () => {
|
||||
numberOfComments++;
|
||||
updateNumberOfComments(1);
|
||||
|
@ -115,7 +121,7 @@
|
|||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (album) {
|
||||
if (album && album.isActivityEnabled) {
|
||||
try {
|
||||
if (isLiked) {
|
||||
const activityId = isLiked.id;
|
||||
|
@ -661,9 +667,10 @@
|
|||
on:onVideoStarted={handleVideoStarted}
|
||||
/>
|
||||
{/if}
|
||||
{#if $slideshowState === SlideshowState.None && isShared}
|
||||
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)}
|
||||
<div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end">
|
||||
<ActivityStatus
|
||||
disabled={!album?.isActivityEnabled}
|
||||
{isLiked}
|
||||
{numberOfComments}
|
||||
{isShowActivity}
|
||||
|
@ -744,6 +751,7 @@
|
|||
>
|
||||
<ActivityViewer
|
||||
{user}
|
||||
disabled={!album.isActivityEnabled}
|
||||
assetType={asset.type}
|
||||
albumOwnerId={album.ownerId}
|
||||
albumId={album.id}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export const numberOfComments = writable<number | undefined>(undefined);
|
||||
export const numberOfComments = writable<number>(0);
|
||||
|
||||
export const setNumberOfComments = (number: number) => {
|
||||
numberOfComments.set(number);
|
||||
};
|
||||
|
||||
export const updateNumberOfComments = (addOrRemove: 1 | -1) => {
|
||||
numberOfComments.update((n) => (n ? n + addOrRemove : undefined));
|
||||
numberOfComments.update((n) => n + addOrRemove);
|
||||
};
|
||||
|
|
|
@ -55,6 +55,7 @@
|
|||
import ActivityViewer from '$lib/components/asset-viewer/activity-viewer.svelte';
|
||||
import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte';
|
||||
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
|
||||
import AlbumOptions from '$lib/components/album-page/album-options.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
|
@ -64,6 +65,12 @@
|
|||
let album = data.album;
|
||||
$: album = data.album;
|
||||
|
||||
$: {
|
||||
if (!album.isActivityEnabled && $numberOfComments === 0) {
|
||||
isShowActivity = false;
|
||||
}
|
||||
}
|
||||
|
||||
enum ViewMode {
|
||||
CONFIRM_DELETE = 'confirm-delete',
|
||||
LINK_SHARING = 'link-sharing',
|
||||
|
@ -73,6 +80,7 @@
|
|||
ALBUM_OPTIONS = 'album-options',
|
||||
VIEW_USERS = 'view-users',
|
||||
VIEW = 'view',
|
||||
OPTIONS = 'options',
|
||||
}
|
||||
|
||||
let backUrl: string = AppRoute.ALBUMS;
|
||||
|
@ -107,6 +115,8 @@
|
|||
assetGridWidth = globalWidth;
|
||||
}
|
||||
}
|
||||
$: showActivityStatus =
|
||||
album.sharedUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0);
|
||||
|
||||
afterNavigate(({ from }) => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
|
@ -128,6 +138,24 @@
|
|||
}
|
||||
});
|
||||
|
||||
const handleToggleEnableActivity = async () => {
|
||||
try {
|
||||
const { data } = await api.albumApi.updateAlbumInfo({
|
||||
id: album.id,
|
||||
updateAlbumDto: {
|
||||
isActivityEnabled: !album.isActivityEnabled,
|
||||
},
|
||||
});
|
||||
album = data;
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: `Activity is ${album.isActivityEnabled ? 'enabled' : 'disabled'}`,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, `Can't ${!album.isActivityEnabled ? 'enable' : 'disable'} activity`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
try {
|
||||
if (isLiked) {
|
||||
|
@ -374,6 +402,7 @@
|
|||
},
|
||||
});
|
||||
currentAlbumName = album.albumName;
|
||||
notificationController.show({ type: NotificationType.Info, message: 'New album name has been saved' });
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to update album name');
|
||||
}
|
||||
|
@ -455,6 +484,7 @@
|
|||
<MenuOption on:click={handleStartSlideshow} text="Slideshow" />
|
||||
{/if}
|
||||
<MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
|
||||
<MenuOption on:click={() => (viewMode = ViewMode.OPTIONS)} text="Options" />
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
</CircleIconButton>
|
||||
|
@ -630,9 +660,10 @@
|
|||
</AssetGrid>
|
||||
{/if}
|
||||
|
||||
{#if album.sharedUsers.length > 0 && !$showAssetViewer}
|
||||
{#if showActivityStatus}
|
||||
<div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end">
|
||||
<ActivityStatus
|
||||
disabled={!album.isActivityEnabled}
|
||||
{isLiked}
|
||||
numberOfComments={$numberOfComments}
|
||||
{isShowActivity}
|
||||
|
@ -648,11 +679,12 @@
|
|||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="activity-panel"
|
||||
class="z-[1002] w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg pl-4"
|
||||
class="z-[2] w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg pl-4"
|
||||
translate="yes"
|
||||
>
|
||||
<ActivityViewer
|
||||
{user}
|
||||
disabled={!album.isActivityEnabled}
|
||||
albumOwnerId={album.ownerId}
|
||||
albumId={album.id}
|
||||
bind:reactions
|
||||
|
@ -700,6 +732,16 @@
|
|||
</ConfirmDialogue>
|
||||
{/if}
|
||||
|
||||
{#if viewMode === ViewMode.OPTIONS}
|
||||
<AlbumOptions
|
||||
{album}
|
||||
{user}
|
||||
on:close={() => (viewMode = ViewMode.VIEW)}
|
||||
on:toggleEnableActivity={handleToggleEnableActivity}
|
||||
on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isEditingDescription}
|
||||
<EditDescriptionModal
|
||||
{album}
|
||||
|
|
|
@ -17,4 +17,5 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
|
|||
shared: false,
|
||||
sharedUsers: [],
|
||||
hasSharedLink: false,
|
||||
isActivityEnabled: true,
|
||||
});
|
||||
|
|
|
@ -24,7 +24,7 @@ const config = {
|
|||
},
|
||||
plugins: [sveltekit()],
|
||||
optimizeDeps: {
|
||||
entries: ['src/**/*.{svelte, ts, html}'],
|
||||
entries: ['src/**/*.{svelte,ts,html}'],
|
||||
},
|
||||
};
|
||||
|
||||
|
|