Compare commits

...

5 Commits

Author SHA1 Message Date
Alex
2c4243b3d0 Deploy 1.8.0_12-dev (#132)
* Update 1.8.0_12
* Update readme
2022-04-29 13:10:42 -05:00
Alex
38e0178c81 Implemented editable album title (#130)
* Replace static title text with a text edit field
* Implement endpoint for updating album info
* Implement changing title
* Only the owner can change the title
2022-04-28 23:46:37 -05:00
Alex
c5c7a134dd Update docker-compose file for faster and cleaner build; update ios version for deployment to test flight 2022-04-24 21:43:45 -05:00
Alex
da9eb61532 Implemented remembering login data with radio button (#126) 2022-04-24 21:33:10 -05:00
Alex
c1ccf026f0 Fixed typo in readme 2022-04-23 21:47:53 -05:00
27 changed files with 515 additions and 88 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1,3 +1,4 @@
# These are supported funding model platforms # These are supported funding model platforms
github: alextran1502 github: alextran1502
custom: https://www.buymeacoffee.com/altran1502?new=1

View File

@@ -6,7 +6,7 @@
[ ] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service [ ] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
[ ] Up version in [docker/docker-compose.dev.yml](/docker/docker-compose.gpu.yml) for `immich_server` service [ ] Up version in [docker/docker-compose.dev.yml](/docker/docker-compose.dev.yml) for `immich_server` service
[ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts) [ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)

View File

@@ -66,17 +66,25 @@ This project is under heavy development, there will be continous functions, feat
- Show curated objects on the search page - Show curated objects on the search page
- Shared album with users on the same server - Shared album with users on the same server
# Development # System Requirement
You can use docker compose for development, there are several services that compose Immich **OS**: Preferred Linux-based operating system (Ubuntu, Debian, MacOS...etc). I haven't tested with `Docker for Windows` as well as `WSL` on Windows
1. NestJs **RAM**: At least 2GB, preffered 4GB.
2. PostgreSQL
3. Redis
4. Nginx
5. TensorFlow
## Populate .env file **Cores**: At least 2 cores, preffered 4 cores.
# Development and Testing out the application
You can use docker compose for development and testing out the application, there are several services that compose Immich:
1. **NestJs** - Backend of the application
2. **PostgreSQL** - Main database of the application
3. **Redis** - For sharing websocket instance between docker instances and background tasks message queue.
4. **Nginx** - Load balancing and optimized file uploading.
5. **TensorFlow** - Object Detection and Image Classification.
## Step 1: Populate .env file
Navigate to `docker` directory and run Navigate to `docker` directory and run
@@ -90,15 +98,44 @@ Notice that if set `ENABLE_MAPBOX` to `true`, you will have to provide `MAPBOX_K
Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is owned by the user that run the `docker-compose` command below. Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is owned by the user that run the `docker-compose` command below.
**Example**
```bash
# Database
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE_NAME=immich
# Upload File Config
UPLOAD_LOCATION=<put-the-path-of-the-upload-folder-here>
# JWT SECRET
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
# MAPBOX
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=false
MAPBOX_KEY=
```
## Step 2: Start the server
To start, run To start, run
```bash ```bash
docker-compose -f ./docker/docker-compose.yml up --build -V docker-compose -f ./docker/docker-compose.yml up
``` ```
If you have a few thousand photos/videos, I suggest running docker-compose with scaling option for the `immich_server` container to handle high I/O load when using fast scrolling.
```bash
docker-compose -f ./docker/docker-compose.yml up --scale immich_server=5
```
The server will be running at `http://your-ip:2283` through `Nginx` The server will be running at `http://your-ip:2283` through `Nginx`
## Register User ## Step 3: Register User
Use the command below on your terminal to create user as we don't have user interface for this function yet. Use the command below on your terminal to create user as we don't have user interface for this function yet.
@@ -111,10 +148,12 @@ curl --location --request POST 'http://your-server-ip:2283/auth/signUp' \
}' }'
``` ```
## Run mobile app ## Step 4: Run mobile app
The app is distributed on several platforms below.
## F-Droid ## F-Droid
You can get the app on F-droid by cliking the image below. You can get the app on F-droid by clicking the image below.
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" [<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid" alt="Get it on F-Droid"
@@ -123,11 +162,7 @@ You can get the app on F-droid by cliking the image below.
## Android ## Android
#### Download latest `apk` in release tab and run on your phone. You can follow this guide on how to do that #### Get the app on Google Play Store [here](https://play.google.com/store/apps/details?id=app.alextran.immich)
- [Run APK on Android](https://www.lifewire.com/install-apk-on-android-4177185)
#### You can also download the app from Google Play Store [here](https://play.google.com/store/apps/details?id=app.alextran.immich)
*The App version might be lagging behind the latest release due to the review process.* *The App version might be lagging behind the latest release due to the review process.*
@@ -137,7 +172,7 @@ You can get the app on F-droid by cliking the image below.
## iOS ## iOS
#### You can download the app from Apple AppStore [here](https://apps.apple.com/us/app/immich/id1613945652): #### Get the app on Apple AppStore [here](https://apps.apple.com/us/app/immich/id1613945652):
*The App version might be lagging behind the latest release due to the review process.* *The App version might be lagging behind the latest release due to the review process.*
@@ -148,7 +183,9 @@ You can get the app on F-droid by cliking the image below.
# Support # Support
If you like the app, find it helpful, and want to support me to offset the cost of publishing to AppStores, you can sponsor the project with [**Github Sponsore**](https://github.com/sponsors/alextran1502). If you like the app, find it helpful, and want to support me to offset the cost of publishing to AppStores, you can sponsor the project with [**Github Sponsore**](https://github.com/sponsors/alextran1502), or one time donation with Buy Me a coffee link below.
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/altran1502)
This is also a meaningful way to give me motivation and encounragment to continue working on the app. This is also a meaningful way to give me motivation and encounragment to continue working on the app.

View File

@@ -1,15 +1,15 @@
# Database # Database
DB_USERNAME=postgres DB_USERNAME=postgres
DB_PASSWORD=postgres DB_PASSWORD=postgres
DB_DATABASE_NAME= DB_DATABASE_NAME=immich
# Upload File Config # Upload File Config
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
# JWT SECRET # JWT SECRET
JWT_SECRET= JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
# MAPBOX # MAPBOX
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY ## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX= ENABLE_MAPBOX=false
MAPBOX_KEY= MAPBOX_KEY=

View File

@@ -2,7 +2,7 @@ version: "3.8"
services: services:
immich_server: immich_server:
image: immich-server-dev:1.7.0 image: immich-server-dev:1.8.0
build: build:
context: ../server context: ../server
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -24,7 +24,7 @@ services:
- immich_network - immich_network
immich_microservices: immich_microservices:
image: immich-microservices-dev:1.7.0 image: immich-microservices-dev:1.8.0
build: build:
context: ../microservices context: ../microservices
dockerfile: Dockerfile dockerfile: Dockerfile

View File

@@ -2,7 +2,7 @@ version: "3.8"
services: services:
immich_server: immich_server:
image: immich-server-dev:1.7.0 image: immich-server-dev:1.8.0
build: build:
context: ../server context: ../server
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -22,7 +22,7 @@ services:
- immich_network - immich_network
immich_microservices: immich_microservices:
image: immich-microservices-dev:1.7.0 image: immich-microservices-dev:1.8.0
build: build:
context: ../microservices context: ../microservices
dockerfile: Dockerfile dockerfile: Dockerfile

View File

@@ -2,10 +2,7 @@ version: "3.8"
services: services:
immich_server: immich_server:
image: immich-server:1.7.0 image: altran1502/immich-server:v1.8.0_12-dev
build:
context: ../server
dockerfile: Dockerfile
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose: expose:
- "3000" - "3000"
@@ -23,10 +20,7 @@ services:
restart: unless-stopped restart: unless-stopped
immich_microservices: immich_microservices:
image: immich-microservices:1.7.0 image: altran1502/immich-microservices:v1.8.0_12-dev
build:
context: ../microservices
dockerfile: Dockerfile
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose: expose:
- "3001" - "3001"

View File

@@ -0,0 +1 @@
* Album name is now editable

View File

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

View File

@@ -3,9 +3,9 @@ const String userInfoBox = "immichBoxUserInfo"; // Box
const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1 const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1
const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2 const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
// SERVER ENDPOINT // Server endpoint
const String serverEndpointKey = 'immichBoxServerEndpoint'; const String serverEndpointKey = 'immichBoxServerEndpoint';
// KEY // Login Info
const String hiveAllAsssetKey = "allAssets"; const String hiveLoginInfoBox = "immichLoginInfoBox";
const String hiveBackupProgressKey = "backupProgressAssets"; const String savedLoginInfoKey = "immichSavedLoginInfoKey";

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart';
@@ -15,7 +16,9 @@ import 'constants/hive_box.dart';
void main() async { void main() async {
await Hive.initFlutter(); await Hive.initFlutter();
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
await Hive.openBox(userInfoBox); await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle( const SystemUiOverlayStyle(

View File

@@ -0,0 +1,20 @@
import 'package:hive/hive.dart';
part 'hive_saved_login_info.model.g.dart';
@HiveType(typeId: 0)
class HiveSavedLoginInfo {
@HiveField(0)
String email;
@HiveField(1)
String password;
@HiveField(2)
String serverUrl;
@HiveField(3)
bool isSaveLogin;
HiveSavedLoginInfo({required this.email, required this.password, required this.serverUrl, required this.isSaveLogin});
}

View File

@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'hive_saved_login_info.model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class HiveSavedLoginInfoAdapter extends TypeAdapter<HiveSavedLoginInfo> {
@override
final int typeId = 0;
@override
HiveSavedLoginInfo read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return HiveSavedLoginInfo(
email: fields[0] as String,
password: fields[1] as String,
serverUrl: fields[2] as String,
isSaveLogin: fields[3] as bool,
);
}
@override
void write(BinaryWriter writer, HiveSavedLoginInfo obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.email)
..writeByte(1)
..write(obj.password)
..writeByte(2)
..write(obj.serverUrl)
..writeByte(3)
..write(obj.isSaveLogin);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is HiveSavedLoginInfoAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -4,6 +4,7 @@ import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/models/login_response.model.dart'; import 'package:immich_mobile/modules/login/models/login_response.model.dart';
import 'package:immich_mobile/shared/services/backup.service.dart'; import 'package:immich_mobile/shared/services/backup.service.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart'; import 'package:immich_mobile/shared/services/device_info.service.dart';
@@ -36,7 +37,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
final BackupService _backupService = BackupService(); final BackupService _backupService = BackupService();
final NetworkService _networkService = NetworkService(); final NetworkService _networkService = NetworkService();
Future<bool> login(String email, String password, String serverEndpoint) async { Future<bool> login(String email, String password, String serverEndpoint, bool isSavedLoginInfo) async {
// Store server endpoint to Hive and test endpoint // Store server endpoint to Hive and test endpoint
if (serverEndpoint[serverEndpoint.length - 1] == "/") { if (serverEndpoint[serverEndpoint.length - 1] == "/") {
var validUrl = serverEndpoint.substring(0, serverEndpoint.length - 1); var validUrl = serverEndpoint.substring(0, serverEndpoint.length - 1);
@@ -76,6 +77,20 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
userId: payload.userId, userId: payload.userId,
userEmail: payload.userEmail, userEmail: payload.userEmail,
); );
if (isSavedLoginInfo) {
// Save login info to local storage
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
savedLoginInfoKey,
HiveSavedLoginInfo(
email: email,
password: password,
isSaveLogin: true,
serverUrl: Hive.box(userInfoBox).get(serverEndpointKey)),
);
} else {
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey);
}
} catch (e) { } catch (e) {
return false; return false;
} }

View File

@@ -1,7 +1,10 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart'; import 'package:immich_mobile/shared/providers/backup.provider.dart';
@@ -12,22 +15,36 @@ class LoginForm extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final usernameController = useTextEditingController(text: 'testuser@email.com'); final usernameController = useTextEditingController.fromValue(TextEditingValue.empty);
final passwordController = useTextEditingController(text: 'password'); final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController = useTextEditingController(text: 'http://192.168.1.216:2283'); final serverEndpointController = useTextEditingController(text: 'http://your-server-ip:2283');
final isSaveLoginInfo = useState<bool>(false);
useEffect(() {
var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
if (loginInfo != null) {
usernameController.text = loginInfo.email;
passwordController.text = loginInfo.password;
serverEndpointController.text = loginInfo.serverUrl;
isSaveLoginInfo.value = loginInfo.isSaveLogin;
}
return null;
}, []);
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300), constraints: const BoxConstraints(maxWidth: 300),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Wrap( child: Wrap(
spacing: 32, spacing: 16,
runSpacing: 32, runSpacing: 16,
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
children: [ children: [
const Image( const Image(
image: AssetImage('assets/immich-logo-no-outline.png'), image: AssetImage('assets/immich-logo-no-outline.png'),
width: 128, width: 100,
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
), ),
Text( Text(
@@ -42,10 +59,29 @@ class LoginForm extends HookConsumerWidget {
EmailInput(controller: usernameController), EmailInput(controller: usernameController),
PasswordInput(controller: passwordController), PasswordInput(controller: passwordController),
ServerEndpointInput(controller: serverEndpointController), ServerEndpointInput(controller: serverEndpointController),
CheckboxListTile(
activeColor: Theme.of(context).primaryColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
dense: true,
side: const BorderSide(color: Colors.grey, width: 1.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
enableFeedback: true,
title: const Text(
"Save login",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey),
),
value: isSaveLoginInfo.value,
onChanged: (switchValue) {
if (switchValue != null) {
isSaveLoginInfo.value = switchValue;
}
},
),
LoginButton( LoginButton(
emailController: usernameController, emailController: usernameController,
passwordController: passwordController, passwordController: passwordController,
serverEndpointController: serverEndpointController, serverEndpointController: serverEndpointController,
isSavedLoginInfo: isSaveLoginInfo.value,
), ),
], ],
), ),
@@ -104,29 +140,34 @@ class LoginButton extends ConsumerWidget {
final TextEditingController emailController; final TextEditingController emailController;
final TextEditingController passwordController; final TextEditingController passwordController;
final TextEditingController serverEndpointController; final TextEditingController serverEndpointController;
final bool isSavedLoginInfo;
const LoginButton( const LoginButton({
{Key? key, Key? key,
required this.emailController, required this.emailController,
required this.passwordController, required this.passwordController,
required this.serverEndpointController}) required this.serverEndpointController,
: super(key: key); required this.isSavedLoginInfo,
}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton( return ElevatedButton(
style: ButtonStyle(
visualDensity: VisualDensity.standard,
padding: MaterialStateProperty.all<EdgeInsets>(const EdgeInsets.symmetric(vertical: 10, horizontal: 25)),
),
onPressed: () async { onPressed: () async {
// This will remove current cache asset state of previous user login. // This will remove current cache asset state of previous user login.
ref.watch(assetProvider.notifier).clearAllAsset(); ref.watch(assetProvider.notifier).clearAllAsset();
var isAuthenticated = await ref var isAuthenticated = await ref
.read(authenticationProvider.notifier) .read(authenticationProvider.notifier)
.login(emailController.text, passwordController.text, serverEndpointController.text); .login(emailController.text, passwordController.text, serverEndpointController.text, isSavedLoginInfo);
if (isAuthenticated) { if (isAuthenticated) {
// Resume backup (if enable) then navigate // Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup(); ref.watch(backupProvider.notifier).resumeBackup();
// AutoRouter.of(context).pushNamed("/home-page");
AutoRouter.of(context).pushNamed("/tab-controller-page"); AutoRouter.of(context).pushNamed("/tab-controller-page");
} else { } else {
ImmichToast.show( ImmichToast.show(
@@ -136,6 +177,9 @@ class LoginButton extends ConsumerWidget {
); );
} }
}, },
child: const Text("Login")); child: const Text(
"Login",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
));
} }
} }

View File

@@ -0,0 +1,53 @@
import 'dart:convert';
class AlbumViewerPageState {
final bool isEditAlbum;
final String editTitleText;
AlbumViewerPageState({
required this.isEditAlbum,
required this.editTitleText,
});
AlbumViewerPageState copyWith({
bool? isEditAlbum,
String? editTitleText,
}) {
return AlbumViewerPageState(
isEditAlbum: isEditAlbum ?? this.isEditAlbum,
editTitleText: editTitleText ?? this.editTitleText,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'isEditAlbum': isEditAlbum});
result.addAll({'editTitleText': editTitleText});
return result;
}
factory AlbumViewerPageState.fromMap(Map<String, dynamic> map) {
return AlbumViewerPageState(
isEditAlbum: map['isEditAlbum'] ?? false,
editTitleText: map['editTitleText'] ?? '',
);
}
String toJson() => json.encode(toMap());
factory AlbumViewerPageState.fromJson(String source) => AlbumViewerPageState.fromMap(json.decode(source));
@override
String toString() => 'AlbumViewerPageState(isEditAlbum: $isEditAlbum, editTitleText: $editTitleText)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AlbumViewerPageState && other.isEditAlbum == isEditAlbum && other.editTitleText == editTitleText;
}
@override
int get hashCode => isEditAlbum.hashCode ^ editTitleText.hashCode;
}

View File

@@ -0,0 +1,50 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/sharing/models/album_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
AlbumViewerNotifier(this.ref) : super(AlbumViewerPageState(editTitleText: "", isEditAlbum: false));
final Ref ref;
void enableEditAlbum() {
state = state.copyWith(isEditAlbum: true);
}
void disableEditAlbum() {
state = state.copyWith(isEditAlbum: false);
}
void setEditTitleText(String newTitle) {
state = state.copyWith(editTitleText: newTitle);
}
void remoteEditTitleText() {
state = state.copyWith(editTitleText: "");
}
void resetState() {
state = state.copyWith(editTitleText: "", isEditAlbum: false);
}
Future<bool> changeAlbumTitle(String albumId, String ownerId, String newAlbumTitle) async {
SharedAlbumService service = SharedAlbumService();
bool isSuccess = await service.changeTitleAlbum(albumId, ownerId, newAlbumTitle);
if (isSuccess) {
state = state.copyWith(editTitleText: "", isEditAlbum: false);
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
return true;
}
state = state.copyWith(editTitleText: "", isEditAlbum: false);
return false;
}
}
final albumViewerProvider = StateNotifierProvider<AlbumViewerNotifier, AlbumViewerPageState>((ref) {
return AlbumViewerNotifier(ref);
});

View File

@@ -138,4 +138,23 @@ class SharedAlbumService {
return false; return false;
} }
} }
Future<bool> changeTitleAlbum(String albumId, String ownerId, String newAlbumTitle) async {
try {
Response res = await _networkService.patchRequest(url: 'shared/updateInfo', data: {
"albumId": albumId,
"ownerId": ownerId,
"albumName": newAlbumTitle,
});
if (res.statusCode != 200) {
return false;
}
return true;
} catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}");
return false;
}
}
} }

View File

@@ -4,6 +4,7 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart'; import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
import 'package:immich_mobile/modules/sharing/providers/album_viewer.provider.dart';
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@@ -27,6 +28,8 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable; final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable;
final selectedAssetsInAlbum = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer; final selectedAssetsInAlbum = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
void _onDeleteAlbumPressed(String albumId) async { void _onDeleteAlbumPressed(String albumId) async {
ImmichLoadingOverlayController.appLoader.show(); ImmichLoadingOverlayController.appLoader.show();
@@ -152,6 +155,24 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
splashRadius: 25, splashRadius: 25,
); );
} else if (isEditAlbum) {
return IconButton(
onPressed: () async {
bool isSuccess =
await ref.watch(albumViewerProvider.notifier).changeAlbumTitle(albumId, userId, newAlbumTitle);
if (!isSuccess) {
ImmichToast.show(
context: context,
msg: "Failed to change album title",
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
},
icon: const Icon(Icons.check_rounded),
splashRadius: 25,
);
} else { } else {
return IconButton( return IconButton(
onPressed: () async => await AutoRouter.of(context).pop(), onPressed: () async => await AutoRouter.of(context).pop(),

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
import 'package:immich_mobile/modules/sharing/providers/album_viewer.provider.dart';
class AlbumViewerEditableTitle extends HookConsumerWidget {
final SharedAlbum albumInfo;
final FocusNode titleFocusNode;
const AlbumViewerEditableTitle({Key? key, required this.albumInfo, required this.titleFocusNode}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final titleTextEditController = useTextEditingController(text: albumInfo.albumName);
void onFocusModeChange() {
if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) {
ref.watch(albumViewerProvider.notifier).setEditTitleText("Untitled");
titleTextEditController.text = "Untitled";
}
}
useEffect(() {
titleFocusNode.addListener(onFocusModeChange);
return () {
titleFocusNode.removeListener(onFocusModeChange);
};
}, []);
return TextField(
onChanged: (value) {
if (value.isEmpty) {
} else {
ref.watch(albumViewerProvider.notifier).setEditTitleText(value);
}
},
focusNode: titleFocusNode,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
controller: titleTextEditController,
onTap: () {
FocusScope.of(context).requestFocus(titleFocusNode);
ref.watch(albumViewerProvider.notifier).setEditTitleText(albumInfo.albumName);
ref.watch(albumViewerProvider.notifier).enableEditAlbum();
if (titleTextEditController.text == 'Untitled') {
titleTextEditController.clear();
}
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
suffixIcon: titleFocusNode.hasFocus
? IconButton(
onPressed: () {
titleTextEditController.clear();
},
icon: const Icon(Icons.cancel_rounded),
splashRadius: 10,
)
: null,
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.transparent),
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.transparent),
borderRadius: BorderRadius.circular(10),
),
focusColor: Colors.grey[300],
fillColor: Colors.grey[200],
filled: titleFocusNode.hasFocus,
hintText: 'Add a title',
),
);
}
}

View File

@@ -12,6 +12,7 @@ import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.da
import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart'; import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
import 'package:immich_mobile/modules/sharing/ui/album_action_outlined_button.dart'; import 'package:immich_mobile/modules/sharing/ui/album_action_outlined_button.dart';
import 'package:immich_mobile/modules/sharing/ui/album_viewer_appbar.dart'; import 'package:immich_mobile/modules/sharing/ui/album_viewer_appbar.dart';
import 'package:immich_mobile/modules/sharing/ui/album_viewer_editable_title.dart';
import 'package:immich_mobile/modules/sharing/ui/album_viewer_thumbnail.dart'; import 'package:immich_mobile/modules/sharing/ui/album_viewer_thumbnail.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@@ -26,6 +27,7 @@ class AlbumViewerPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
FocusNode titleFocusNode = useFocusNode();
ScrollController _scrollController = useScrollController(); ScrollController _scrollController = useScrollController();
AsyncValue<SharedAlbum> _albumInfo = ref.watch(sharedAlbumDetailProvider(albumId)); AsyncValue<SharedAlbum> _albumInfo = ref.watch(sharedAlbumDetailProvider(albumId));
@@ -83,13 +85,18 @@ class AlbumViewerPage extends HookConsumerWidget {
} }
} }
Widget _buildTitle(String title) { Widget _buildTitle(SharedAlbum albumInfo) {
return Padding( return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16), padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
child: Text( child: userId == albumInfo.ownerId
title, ? AlbumViewerEditableTitle(
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), albumInfo: albumInfo,
), titleFocusNode: titleFocusNode,
)
: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(albumInfo.albumName, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
),
); );
} }
@@ -124,7 +131,7 @@ class AlbumViewerPage extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildTitle(albumInfo.albumName), _buildTitle(albumInfo),
_buildAlbumDateRange(albumInfo), _buildAlbumDateRange(albumInfo),
SizedBox( SizedBox(
height: 60, height: 60,
@@ -204,31 +211,36 @@ class AlbumViewerPage extends HookConsumerWidget {
} }
Widget _buildBody(SharedAlbum albumInfo) { Widget _buildBody(SharedAlbum albumInfo) {
return Stack(children: [ return GestureDetector(
DraggableScrollbar.semicircle( onTap: () {
backgroundColor: Theme.of(context).primaryColor, titleFocusNode.unfocus();
controller: _scrollController, },
heightScrollThumb: 48.0, child: Stack(children: [
child: CustomScrollView( DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
controller: _scrollController, controller: _scrollController,
slivers: [ heightScrollThumb: 48.0,
_buildHeader(albumInfo), child: CustomScrollView(
SliverPersistentHeader( controller: _scrollController,
pinned: true, slivers: [
delegate: ImmichSliverPersistentAppBarDelegate( _buildHeader(albumInfo),
minHeight: 50, SliverPersistentHeader(
maxHeight: 50, pinned: true,
child: Container( delegate: ImmichSliverPersistentAppBarDelegate(
color: immichBackgroundColor, minHeight: 50,
child: _buildControlButton(albumInfo), maxHeight: 50,
child: Container(
color: immichBackgroundColor,
child: _buildControlButton(albumInfo),
),
), ),
), ),
), _buildImageGrid(albumInfo)
_buildImageGrid(albumInfo) ],
], ),
), ),
), ]),
]); );
} }
return Scaffold( return Scaffold(

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.7.0+11 version: 1.8.0+12
environment: environment:
sdk: ">=2.15.1 <3.0.0" sdk: ">=2.15.1 <3.0.0"

View File

@@ -14,6 +14,7 @@ import {
Headers, Headers,
Delete, Delete,
Logger, Logger,
Patch,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';

View File

@@ -0,0 +1,12 @@
import { IsNotEmpty } from 'class-validator';
export class UpdateShareAlbumDto {
@IsNotEmpty()
albumId: string;
@IsNotEmpty()
albumName: string;
@IsNotEmpty()
ownerId: string;
}

View File

@@ -2,10 +2,11 @@ import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Validatio
import { SharingService } from './sharing.service'; import { SharingService } from './sharing.service';
import { CreateSharedAlbumDto } from './dto/create-shared-album.dto'; import { CreateSharedAlbumDto } from './dto/create-shared-album.dto';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { AddAssetsDto } from './dto/add-assets.dto'; import { AddAssetsDto } from './dto/add-assets.dto';
import { AddUsersDto } from './dto/add-users.dto'; import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('shared') @Controller('shared')
@@ -52,4 +53,9 @@ export class SharingController {
async leaveAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) { async leaveAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
return await this.sharingService.leaveAlbum(authUser, albumId); return await this.sharingService.leaveAlbum(authUser, albumId);
} }
@Patch('/updateInfo')
async updateAlbumInfo(@GetAuthUser() authUser, @Body(ValidationPipe) updateAlbumInfoDto: UpdateShareAlbumDto) {
return await this.sharingService.updateAlbumTitle(authUser, updateAlbumInfoDto);
}
} }

View File

@@ -12,6 +12,7 @@ import { UserSharedAlbumEntity } from './entities/user-shared-album.entity';
import _ from 'lodash'; import _ from 'lodash';
import { AddUsersDto } from './dto/add-users.dto'; import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
@Injectable() @Injectable()
export class SharingService { export class SharingService {
@@ -184,4 +185,15 @@ export class SharingService {
return await this.assetSharedAlbumRepository.save([...newRecords]); return await this.assetSharedAlbumRepository.save([...newRecords]);
} }
async updateAlbumTitle(authUser: AuthUserDto, updateShareAlbumDto: UpdateShareAlbumDto) {
if (authUser.id != updateShareAlbumDto.ownerId) {
throw new BadRequestException('Unauthorized to change album info');
}
const sharedAlbum = await this.sharedAlbumRepository.findOne({ where: { id: updateShareAlbumDto.albumId } });
sharedAlbum.albumName = updateShareAlbumDto.albumName;
return await this.sharedAlbumRepository.save(sharedAlbum);
}
} }

View File

@@ -3,7 +3,7 @@
export const serverVersion = { export const serverVersion = {
major: 1, major: 1,
minor: 7, minor: 8,
patch: 0, patch: 0,
build: 11, build: 12,
}; };