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
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.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)

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
- 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
2. PostgreSQL
3. Redis
4. Nginx
5. TensorFlow
**RAM**: At least 2GB, preffered 4GB.
## 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
@@ -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.
**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
```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`
## 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.
@@ -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
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"
alt="Get it on F-Droid"
@@ -123,11 +162,7 @@ You can get the app on F-droid by cliking the image below.
## Android
#### Download latest `apk` in release tab and run on your phone. You can follow this guide on how to do that
- [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)
#### Get the app on 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.*
@@ -137,7 +172,7 @@ You can get the app on F-droid by cliking the image below.
## 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.*
@@ -148,7 +183,9 @@ You can get the app on F-droid by cliking the image below.
# 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.7.0"
version_number: "1.8.0"
)
increment_build_number(
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 deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
// SERVER ENDPOINT
// Server endpoint
const String serverEndpointKey = 'immichBoxServerEndpoint';
// KEY
const String hiveAllAsssetKey = "allAssets";
const String hiveBackupProgressKey = "backupProgressAssets";
// Login Info
const String hiveLoginInfoBox = "immichLoginInfoBox";
const String savedLoginInfoKey = "immichSavedLoginInfoKey";

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
@@ -15,7 +16,9 @@ import 'constants/hive_box.dart';
void main() async {
await Hive.initFlutter();
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
SystemChrome.setSystemUIOverlayStyle(
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:immich_mobile/constants/hive_box.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/shared/services/backup.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 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
if (serverEndpoint[serverEndpoint.length - 1] == "/") {
var validUrl = serverEndpoint.substring(0, serverEndpoint.length - 1);
@@ -76,6 +77,20 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
userId: payload.userId,
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) {
return false;
}

View File

@@ -1,7 +1,10 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive/hive.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/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart';
@@ -12,22 +15,36 @@ class LoginForm extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final usernameController = useTextEditingController(text: 'testuser@email.com');
final passwordController = useTextEditingController(text: 'password');
final serverEndpointController = useTextEditingController(text: 'http://192.168.1.216:2283');
final usernameController = useTextEditingController.fromValue(TextEditingValue.empty);
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
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(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: SingleChildScrollView(
child: Wrap(
spacing: 32,
runSpacing: 32,
spacing: 16,
runSpacing: 16,
alignment: WrapAlignment.center,
children: [
const Image(
image: AssetImage('assets/immich-logo-no-outline.png'),
width: 128,
width: 100,
filterQuality: FilterQuality.high,
),
Text(
@@ -42,10 +59,29 @@ class LoginForm extends HookConsumerWidget {
EmailInput(controller: usernameController),
PasswordInput(controller: passwordController),
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(
emailController: usernameController,
passwordController: passwordController,
serverEndpointController: serverEndpointController,
isSavedLoginInfo: isSaveLoginInfo.value,
),
],
),
@@ -104,29 +140,34 @@ class LoginButton extends ConsumerWidget {
final TextEditingController emailController;
final TextEditingController passwordController;
final TextEditingController serverEndpointController;
final bool isSavedLoginInfo;
const LoginButton(
{Key? key,
required this.emailController,
required this.passwordController,
required this.serverEndpointController})
: super(key: key);
const LoginButton({
Key? key,
required this.emailController,
required this.passwordController,
required this.serverEndpointController,
required this.isSavedLoginInfo,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
style: ButtonStyle(
visualDensity: VisualDensity.standard,
padding: MaterialStateProperty.all<EdgeInsets>(const EdgeInsets.symmetric(vertical: 10, horizontal: 25)),
),
onPressed: () async {
// This will remove current cache asset state of previous user login.
ref.watch(assetProvider.notifier).clearAllAsset();
var isAuthenticated = await ref
.read(authenticationProvider.notifier)
.login(emailController.text, passwordController.text, serverEndpointController.text);
.login(emailController.text, passwordController.text, serverEndpointController.text, isSavedLoginInfo);
if (isAuthenticated) {
// Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup();
// AutoRouter.of(context).pushNamed("/home-page");
AutoRouter.of(context).pushNamed("/tab-controller-page");
} else {
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;
}
}
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:immich_mobile/constants/immich_colors.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/shared_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -27,6 +28,8 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
Widget build(BuildContext context, WidgetRef ref) {
final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable;
final selectedAssetsInAlbum = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
void _onDeleteAlbumPressed(String albumId) async {
ImmichLoadingOverlayController.appLoader.show();
@@ -152,6 +155,24 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
icon: const Icon(Icons.close_rounded),
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 {
return IconButton(
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/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_editable_title.dart';
import 'package:immich_mobile/modules/sharing/ui/album_viewer_thumbnail.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@@ -26,6 +27,7 @@ class AlbumViewerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
FocusNode titleFocusNode = useFocusNode();
ScrollController _scrollController = useScrollController();
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(
padding: const EdgeInsets.only(left: 16.0, top: 16),
child: Text(
title,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
child: userId == albumInfo.ownerId
? AlbumViewerEditableTitle(
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(albumInfo.albumName),
_buildTitle(albumInfo),
_buildAlbumDateRange(albumInfo),
SizedBox(
height: 60,
@@ -204,31 +211,36 @@ class AlbumViewerPage extends HookConsumerWidget {
}
Widget _buildBody(SharedAlbum albumInfo) {
return Stack(children: [
DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
controller: _scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
return GestureDetector(
onTap: () {
titleFocusNode.unfocus();
},
child: Stack(children: [
DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
controller: _scrollController,
slivers: [
_buildHeader(albumInfo),
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
minHeight: 50,
maxHeight: 50,
child: Container(
color: immichBackgroundColor,
child: _buildControlButton(albumInfo),
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: _scrollController,
slivers: [
_buildHeader(albumInfo),
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
minHeight: 50,
maxHeight: 50,
child: Container(
color: immichBackgroundColor,
child: _buildControlButton(albumInfo),
),
),
),
),
_buildImageGrid(albumInfo)
],
_buildImageGrid(albumInfo)
],
),
),
),
]);
]),
);
}
return Scaffold(

View File

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

View File

@@ -14,6 +14,7 @@ import {
Headers,
Delete,
Logger,
Patch,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
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 { CreateSharedAlbumDto } from './dto/create-shared-album.dto';
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 { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
@UseGuards(JwtAuthGuard)
@Controller('shared')
@@ -52,4 +53,9 @@ export class SharingController {
async leaveAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
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 { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
@Injectable()
export class SharingService {
@@ -184,4 +185,15 @@ export class SharingService {
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 = {
major: 1,
minor: 7,
minor: 8,
patch: 0,
build: 11,
build: 12,
};