Compare commits

...

30 Commits

Author SHA1 Message Date
Tran, Alex
4f47c8c06b Update readme 2022-02-10 20:42:22 -06:00
Alex
de1dbcea9c Implemented EXIF store and display (#19)
* Added EXIF extracting in the backend
* Added EXIF displaying on `image_viewer_page.dart`
* Added Icon for backup option not enable
2022-02-10 20:40:11 -06:00
Alex Tran
d1498506a8 Remove TensorFlow dependency to work with ARM64 2022-02-09 21:06:37 -06:00
Alex Tran
9bcbdd31ce Added arm64 to github action for docker build 2022-02-09 20:52:38 -06:00
Alex
38c968d47e Support HEIC/HEIF (#16)
* Support HEIC/HEIF backup
* Storing backup directly from original file from the phone
* Directly read and backup video file - Improve performance on video backup
2022-02-09 20:48:06 -06:00
Alex
f578ca6d47 Implemented bottom app bar with control buttons for asset's operation (#15) 2022-02-09 12:41:02 -06:00
Alex Tran
b04e69fd66 Update readme with screenshots 2022-02-08 14:18:51 -06:00
Alex Tran
0a1e28a08f Update readme 2022-02-08 14:06:58 -06:00
Alex Tran
9fdaa82d77 Update readme with gif 2022-02-08 14:05:31 -06:00
Alex Tran
88123b1cd2 Update readme with gif 2022-02-08 13:59:25 -06:00
Alex Tran
42c4c9dba1 Update readme with gif 2022-02-08 13:56:37 -06:00
Alex Tran
e63dc49475 Update readme with gif 2022-02-08 13:55:18 -06:00
Alex Tran
690f30f3dd Update Artifact path 2022-02-08 11:57:49 -06:00
Alex Tran
561b030e80 Update github action 2022-02-08 11:43:38 -06:00
Alex Tran
4756c075b6 Added work flow to build APK on push to master 2022-02-08 11:36:43 -06:00
Alex
328f382f86 Implemented multi select interaction (#13) 2022-02-08 11:24:49 -06:00
Alex Tran
6ad77e9434 Update readme 2022-02-07 23:55:30 -06:00
Alex
919928ab70 Implemented auto backup (#11) 2022-02-07 23:42:35 -06:00
Alex Tran
2a4d4ea999 Change docker hub name to the correct one 2022-02-07 16:20:21 -06:00
Alex Tran
547ce49500 Remove armv7-64bit for docker build as Tensorflow doesn't support that architecture, add amd64 2022-02-07 15:52:42 -06:00
Alex Tran
f4970ed053 Update readme 2022-02-07 15:44:03 -06:00
Alex Tran
9cf083decf Update readme 2022-02-07 15:25:51 -06:00
Alex Tran
d078367c04 change path in of target docker file in docker-compose for server 2022-02-07 15:11:59 -06:00
Alex Tran
a8edc85183 rename docker-minimal to dockerfile as target for github action 2022-02-07 15:06:30 -06:00
Alex Tran
5d48de7fa9 Change to npm instead of yarn in docker image to test for build error on github action 2022-02-07 14:58:23 -06:00
Alex Tran
82beb040bc Remove production build on docker file to test build for arm architecture 2022-02-07 14:38:02 -06:00
Alex Tran
03864e52ff Enable automated dockerhub image build 2022-02-07 08:55:15 -06:00
Alex
c24fb403c5 Implemented load new image when navigating back from backup page (#9) 2022-02-06 20:31:32 -06:00
Alex
1d3ee2008c Update workflow to build on pull request only 2022-02-06 13:11:17 -06:00
schklom
c917875943 Automated multi-platform build and DockerHub publication (#8)
* Automated multi-arch build

This setup uses GitHub Actions to build an image for arm/v7 and arm64 then publish them on DockerHub (you need to setup repo secrets first) every time you want (workflow_dispatch), every push, every pull requests (pull_requests), or on a schedule (cronjob) :)

Remove the triggers you don't want.

Reminder: if you ever move the Dockerfile (or some dependencies), you will have to correct the Dockerfile path (and/or the context path).

* Create dependabot.yml

This checks the dependencies' versions for the Actions everyday and creates a pull request if there are new versions available.
2022-02-06 13:06:01 -06:00
71 changed files with 20070 additions and 7226 deletions

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

46
.github/workflows/Build+push Immich.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Build+push Immich
on:
# Triggers the workflow on push or pull request events but only for the main branch
#schedule:
# * is a special character in YAML so you have to quote this string
#- cron: '0 0 * * *'
workflow_dispatch:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
buildandpush:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2.4.0
with:
ref: "main" # branch
# https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
# https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1.6.0
# https://github.com/docker/login-action#docker-hub
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push Immich
uses: docker/build-push-action@v2.9.0
with:
context: ./server
file: ./server/Dockerfile
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
platforms: linux/arm/v7,linux/amd64,linux/arm64
pull: true
push: true
tags: |
altran1502/immich-server:latest

32
.github/workflows/build_apk.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Build APK Android
on:
workflow_dispatch:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./mobile
steps:
- uses: actions/checkout@v2
# Build
- uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
cache-key: flutter2.10 # optional, change this to force refresh cache
- run: flutter --version
- run: flutter pub get
- run: flutter build apk
- run: flutter build appbundle
# Upload Artifact
- uses: actions/upload-artifact@v2
with:
name: release-apk
path: mobile/build/app/outputs/apk/release/app-release.apk

View File

@@ -4,19 +4,47 @@
# IMMICH # IMMICH
Self-hosted Photo backup solution directly from your mobile phone. Self-hosted photo and video backup solution directly from your mobile phone.
![](https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif)
Loading ~4000 images/videos
## Screenshots
<p align="left">
<img src="design/sc1.PNG" width="150" title="Login With Custom URL">
<img src="design/sc2.PNG" width="150" title="Backup Setting Info">
<img src="design/sc4.PNG" width="150" title="Home Page">
<img src="design/sc3.PNG" width="150" title="Multiple seelct">
<img src="design/sc5.PNG" width="150" title="Multipe select group">
</p>
# Note # Note
**!! NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS !!**
This project is under heavy development, there will be continous functions, features and api changes. This project is under heavy development, there will be continous functions, features and api changes.
**!! NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS !!** # Features
[x] Upload assets(videos/images)
[x] View assets
[x] Quick navigation with drag scroll bar
[x] Auto Backup
[x] Support HEIC/HEIF Backup
[x] Extract and display EXIF info
# Development # Development
You can use docker compose for development, there are several services that compose Immich You can use docker compose for development, there are several services that compose Immich
1. The server 1. NestJs
2. PostgreSQL 2. PostgreSQL
3. Redis 3. Redis
4. Nginx 4. Nginx
@@ -79,7 +107,7 @@ flutter run --release
# Known Issue # Known Issue
TensorFlow doesn't run with older CPU architecture, it requires CPU with AVX and AVX2 instruction set. If you encounter error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command ad make sure you see `AVX` and `AVX2`. Otherwise, switch to a different VM/desktop with different architecture. TensorFlow doesn't run with older CPU architecture, it requires CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`. Otherwise, switch to a different VM/desktop with different architecture.
```bash ```bash
more /proc/cpuinfo | grep flags more /proc/cpuinfo | grep flags

BIN
design/sc1.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

BIN
design/sc2.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

BIN
design/sc3.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 KiB

BIN
design/sc4.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

BIN
design/sc5.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

View File

@@ -1,16 +1 @@
# immich_mobile # Immich Mobile Application - Flutter
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
Few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook)
For help getting started with Flutter, view our
[online documentation](https://flutter.dev/docs), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -4,6 +4,7 @@ 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/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart'; import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'constants/hive_box.dart'; import 'constants/hive_box.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
@@ -36,6 +37,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
debugPrint("[APP STATE] resumed"); debugPrint("[APP STATE] resumed");
ref.read(appStateProvider.notifier).state = AppStateEnum.resumed; ref.read(appStateProvider.notifier).state = AppStateEnum.resumed;
ref.read(backupProvider.notifier).resumeBackup();
break; break;
case AppLifecycleState.inactive: case AppLifecycleState.inactive:
debugPrint("[APP STATE] inactive"); debugPrint("[APP STATE] inactive");
@@ -53,7 +55,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
} }
Future<void> initApp() async { Future<void> initApp() async {
// WidgetsBinding.instance?.addObserver(this); WidgetsBinding.instance?.addObserver(this);
} }
@override @override

View File

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

View File

@@ -0,0 +1,21 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class ImageViewerPageStateNotifier extends StateNotifier<ImageViewerPageState> {
ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false));
void toggleBottomSheet() {
bool isBottomSheetEnable = state.isBottomSheetEnable;
if (isBottomSheetEnable) {
state.copyWith(isBottomSheetEnable: false);
} else {
state.copyWith(isBottomSheetEnable: true);
}
}
}
final homePageStateProvider = StateNotifierProvider<ImageViewerPageStateNotifier, ImageViewerPageState>(
((ref) => ImageViewerPageStateNotifier()));

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:intl/intl.dart';
import 'package:path/path.dart' as p;
class ExifBottomSheet extends ConsumerWidget {
final ImmichAssetWithExif assetDetail;
const ExifBottomSheet({Key? key, required this.assetDetail}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
child: ListView(
children: [
assetDetail.exifInfo?.dateTimeOriginal != null
? Text(
DateFormat('E, LLL d, y • h:mm a').format(
DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!),
),
style: TextStyle(
color: Colors.grey[400],
fontWeight: FontWeight.bold,
fontSize: 14,
),
)
: Container(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
"Add Description...",
style: TextStyle(
color: Colors.grey[500],
fontSize: 11,
),
),
),
// Location
assetDetail.exifInfo?.latitude != null
? Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
color: Colors.grey[600],
),
Text(
"LOCATION",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
),
Text(
"${assetDetail.exifInfo!.latitude!.toStringAsFixed(4)}, ${assetDetail.exifInfo!.longitude!.toStringAsFixed(4)}",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
)
],
),
)
: Container(),
// Detail
assetDetail.exifInfo != null
? Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
color: Colors.grey[600],
),
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"DETAILS",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
),
),
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.image),
title: Text(
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
"${assetDetail.exifInfo?.exifImageHeight!} x ${assetDetail.exifInfo?.exifImageWidth!} ${assetDetail.exifInfo?.fileSizeInByte!}B "),
),
assetDetail.exifInfo?.make != null
? ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.camera),
title: Text(
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / assetDetail.exifInfo!.exposureTime!).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "),
)
: Container()
],
),
)
: Container()
],
),
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
const TopControlAppBar({Key? key, required this.asset, required this.onMoreInfoPressed}) : super(key: key);
final ImmichAsset asset;
final Function onMoreInfoPressed;
@override
Widget build(BuildContext context) {
double iconSize = 18.0;
return AppBar(
foregroundColor: Colors.grey[100],
toolbarHeight: 60,
backgroundColor: Colors.black,
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop();
},
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
size: 20.0,
),
),
actions: [
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
print("backup");
},
icon: const Icon(Icons.backup_outlined),
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
print("favorite");
},
icon: asset.isFavorite ? const Icon(Icons.favorite_rounded) : const Icon(Icons.favorite_border_rounded),
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onMoreInfoPressed();
},
icon: const Icon(Icons.more_horiz_rounded))
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View File

@@ -0,0 +1,85 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.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/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:photo_view/photo_view.dart';
// ignore: must_be_immutable
class ImageViewerPage extends HookConsumerWidget {
final String imageUrl;
final String heroTag;
final String thumbnailUrl;
final ImmichAsset asset;
final AssetService _assetService = AssetService();
ImmichAssetWithExif? assetDetail;
ImageViewerPage(
{Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl, required this.asset})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
getAssetExif() async {
assetDetail = await _assetService.getAssetById(asset.id);
}
useEffect(() {
getAssetExif();
}, []);
return Scaffold(
backgroundColor: Colors.black,
appBar: TopControlAppBar(
asset: asset,
onMoreInfoPressed: () {
showModalBottomSheet(
backgroundColor: Colors.black,
barrierColor: Colors.transparent,
isScrollControlled: false,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
});
},
),
body: Center(
child: Hero(
tag: heroTag,
child: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
errorWidget: (context, url, error) => const Icon(Icons.error),
imageBuilder: (context, imageProvider) {
return PhotoView(imageProvider: imageProvider);
},
placeholder: (context, url) {
return CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
placeholderFadeInDuration: const Duration(milliseconds: 0),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) => const Icon(Icons.error),
);
},
),
),
),
);
}
}

View File

@@ -0,0 +1,66 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class HomePageState {
final bool isMultiSelectEnable;
final Set<ImmichAsset> selectedItems;
final Set<String> selectedDateGroup;
HomePageState({
required this.isMultiSelectEnable,
required this.selectedItems,
required this.selectedDateGroup,
});
HomePageState copyWith({
bool? isMultiSelectEnable,
Set<ImmichAsset>? selectedItems,
Set<String>? selectedDateGroup,
}) {
return HomePageState(
isMultiSelectEnable: isMultiSelectEnable ?? this.isMultiSelectEnable,
selectedItems: selectedItems ?? this.selectedItems,
selectedDateGroup: selectedDateGroup ?? this.selectedDateGroup,
);
}
Map<String, dynamic> toMap() {
return {
'isMultiSelectEnable': isMultiSelectEnable,
'selectedItems': selectedItems.map((x) => x.toMap()).toList(),
'selectedDateGroup': selectedDateGroup.toList(),
};
}
factory HomePageState.fromMap(Map<String, dynamic> map) {
return HomePageState(
isMultiSelectEnable: map['isMultiSelectEnable'] ?? false,
selectedItems: Set<ImmichAsset>.from(map['selectedItems']?.map((x) => ImmichAsset.fromMap(x))),
selectedDateGroup: Set<String>.from(map['selectedDateGroup']),
);
}
String toJson() => json.encode(toMap());
factory HomePageState.fromJson(String source) => HomePageState.fromMap(json.decode(source));
@override
String toString() =>
'HomePageState(isMultiSelectEnable: $isMultiSelectEnable, selectedItems: $selectedItems, selectedDateGroup: $selectedDateGroup)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final setEquals = const DeepCollectionEquality().equals;
return other is HomePageState &&
other.isMultiSelectEnable == isMultiSelectEnable &&
setEquals(other.selectedItems, selectedItems) &&
setEquals(other.selectedDateGroup, selectedDateGroup);
}
@override
int get hashCode => isMultiSelectEnable.hashCode ^ selectedItems.hashCode ^ selectedDateGroup.hashCode;
}

View File

@@ -1,16 +1,20 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart'; import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart'; import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:intl/intl.dart';
import 'package:collection/collection.dart';
class AssetNotifier extends StateNotifier<List<ImmichAssetGroupByDate>> { class AssetNotifier extends StateNotifier<List<ImmichAssetGroupByDate>> {
final imagePerPage = 100;
final AssetService _assetService = AssetService(); final AssetService _assetService = AssetService();
AssetNotifier() : super([]); AssetNotifier() : super([]);
late String? nextPageKey = ""; late String? nextPageKey = "";
bool isFetching = false; bool isFetching = false;
getImmichAssets() async { // Get All assets
getAllAssets() async {
GetAllAssetResponse? res = await _assetService.getAllAsset(); GetAllAssetResponse? res = await _assetService.getAllAsset();
nextPageKey = res?.nextPageKey; nextPageKey = res?.nextPageKey;
@@ -21,10 +25,11 @@ class AssetNotifier extends StateNotifier<List<ImmichAssetGroupByDate>> {
} }
} }
getMoreAsset() async { // Get Asset From The Past
getOlderAsset() async {
if (nextPageKey != null && !isFetching) { if (nextPageKey != null && !isFetching) {
isFetching = true; isFetching = true;
GetAllAssetResponse? res = await _assetService.getMoreAsset(nextPageKey); GetAllAssetResponse? res = await _assetService.getOlderAsset(nextPageKey);
if (res != null) { if (res != null) {
nextPageKey = res.nextPageKey; nextPageKey = res.nextPageKey;
@@ -48,6 +53,40 @@ class AssetNotifier extends StateNotifier<List<ImmichAssetGroupByDate>> {
} }
} }
// Get newer asset from the current time
getNewAsset() async {
if (state.isNotEmpty) {
var latestGroup = state.first;
// Sort the last asset group and put the lastest asset in front.
latestGroup.assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
var latestAsset = latestGroup.assets.first;
var formatDateTemplate = 'y-MM-dd';
var latestAssetDateText = DateFormat(formatDateTemplate).format(DateTime.parse(latestAsset.createdAt));
List<ImmichAsset> newAssets = await _assetService.getNewAsset(latestAsset.createdAt);
if (newAssets.isEmpty) {
return;
}
// Grouping by data
var groupByDateList = groupBy<ImmichAsset, String>(
newAssets, (asset) => DateFormat(formatDateTemplate).format(DateTime.parse(asset.createdAt)));
groupByDateList.forEach((groupDateInFormattedText, assets) {
if (groupDateInFormattedText != latestAssetDateText) {
ImmichAssetGroupByDate newGroup = ImmichAssetGroupByDate(assets: assets, date: groupDateInFormattedText);
state = [newGroup, ...state];
} else {
latestGroup.assets.insertAll(0, assets);
state = [latestGroup, ...state.sublist(1)];
}
});
}
}
clearAllAsset() { clearAllAsset() {
state = []; state = [];
} }

View File

@@ -0,0 +1,63 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class HomePageStateNotifier extends StateNotifier<HomePageState> {
HomePageStateNotifier()
: super(
HomePageState(
isMultiSelectEnable: false,
selectedItems: {},
selectedDateGroup: {},
),
);
void addSelectedDateGroup(String dateGroupTitle) {
state = state.copyWith(selectedDateGroup: {...state.selectedDateGroup, dateGroupTitle});
}
void removeSelectedDateGroup(String dateGroupTitle) {
var currentDateGroup = state.selectedDateGroup;
currentDateGroup.removeWhere((e) => e == dateGroupTitle);
state = state.copyWith(selectedDateGroup: currentDateGroup);
}
void enableMultiSelect(Set<ImmichAsset> selectedItems) {
state = state.copyWith(isMultiSelectEnable: true, selectedItems: selectedItems);
}
void disableMultiSelect() {
state = state.copyWith(isMultiSelectEnable: false, selectedItems: {}, selectedDateGroup: {});
}
void addSingleSelectedItem(ImmichAsset asset) {
state = state.copyWith(selectedItems: {...state.selectedItems, asset});
}
void addMultipleSelectedItems(List<ImmichAsset> assets) {
state = state.copyWith(selectedItems: {...state.selectedItems, ...assets});
}
void removeSingleSelectedItem(ImmichAsset asset) {
Set<ImmichAsset> currentList = state.selectedItems;
currentList.removeWhere((e) => e.id == asset.id);
state = state.copyWith(selectedItems: currentList);
}
void removeMultipleSelectedItem(List<ImmichAsset> assets) {
Set<ImmichAsset> currentList = state.selectedItems;
for (ImmichAsset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(selectedItems: currentList);
}
}
final homePageStateProvider =
StateNotifierProvider<HomePageStateNotifier, HomePageState>(((ref) => HomePageStateNotifier()));

View File

@@ -2,6 +2,8 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart'; import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
class AssetService { class AssetService {
@@ -17,9 +19,10 @@ class AssetService {
} catch (e) { } catch (e) {
debugPrint("Error getAllAsset ${e.toString()}"); debugPrint("Error getAllAsset ${e.toString()}");
} }
return null;
} }
Future<GetAllAssetResponse?> getMoreAsset(String? nextPageKey) async { Future<GetAllAssetResponse?> getOlderAsset(String? nextPageKey) async {
try { try {
var res = await _networkService.getRequest( var res = await _networkService.getRequest(
url: "asset/all?nextPageKey=$nextPageKey", url: "asset/all?nextPageKey=$nextPageKey",
@@ -34,5 +37,43 @@ class AssetService {
} catch (e) { } catch (e) {
debugPrint("Error getAllAsset ${e.toString()}"); debugPrint("Error getAllAsset ${e.toString()}");
} }
return null;
}
Future<List<ImmichAsset>> getNewAsset(String latestDate) async {
try {
var res = await _networkService.getRequest(
url: "asset/new?latestDate=$latestDate",
);
List<dynamic> decodedData = jsonDecode(res.toString());
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
if (result.isNotEmpty) {
return result;
}
return [];
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
return [];
}
}
Future<ImmichAssetWithExif?> getAssetById(String assetId) async {
try {
var res = await _networkService.getRequest(
url: "asset/assetById/$assetId",
);
Map<String, dynamic> decodedData = jsonDecode(res.toString());
ImmichAssetWithExif result = ImmichAssetWithExif.fromMap(decodedData);
print("result $result");
return result;
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
return null;
}
} }
} }

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
class ControlBottomAppBar extends StatelessWidget {
const ControlBottomAppBar({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Positioned(
bottom: 0,
left: 0,
child: Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height * 0.15,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(topLeft: Radius.circular(15), topRight: Radius.circular(15)),
color: Colors.grey[300]?.withOpacity(0.98),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ControlBoxButton(
iconData: Icons.delete_forever_rounded,
label: "Delete",
onPressed: () {
showDialog(
context: context,
builder: (BuildContext context) {
return const DeleteDialog();
},
);
},
),
],
),
)
],
),
),
);
}
}
class ControlBoxButton extends StatelessWidget {
const ControlBoxButton({Key? key, required this.label, required this.iconData, required this.onPressed})
: super(key: key);
final String label;
final IconData iconData;
final Function onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 60,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
onPressed: () {
onPressed();
},
icon: Icon(iconData, size: 30),
),
Text(label)
],
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:intl/intl.dart';
class DailyTitleText extends ConsumerWidget {
const DailyTitleText({
Key? key,
required this.isoDate,
required this.assetGroup,
}) : super(key: key);
final String isoDate;
final List<ImmichAsset> assetGroup;
@override
Widget build(BuildContext context, WidgetRef ref) {
var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(isoDate).year;
var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy';
var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
void _handleTitleIconClick() {
if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedDateGroup.length == 1 &&
selectedItems.length <= assetGroup.length) {
// Multi select is active - click again on the icon while it is the only active group -> disable multi select
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
} else if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedItems.length != assetGroup.length) {
// Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText);
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable && selectedDateGroup.contains(dateText) && selectedDateGroup.length > 1) {
ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText);
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText);
ref.watch(homePageStateProvider.notifier).addMultipleSelectedItems(assetGroup);
} else {
ref.watch(homePageStateProvider.notifier).enableMultiSelect(assetGroup.toSet());
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText);
}
}
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 12.0, right: 12.0),
child: Row(
children: [
Text(
dateText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const Spacer(),
GestureDetector(
onTap: _handleTitleIconClick,
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
? Icon(
Icons.check_circle_rounded,
color: Theme.of(context).primaryColor,
)
: const Icon(
Icons.check_circle_outline_rounded,
color: Colors.grey,
),
)
],
),
),
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
class DeleteDialog extends StatelessWidget {
const DeleteDialog({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: Colors.grey[200],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: const Text("Delete Permanently"),
content: const Text("These items will be permanently deleted from Immich and from your device"),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text(
"Cancel",
style: TextStyle(color: Colors.blueGrey),
),
),
TextButton(
onPressed: () {},
child: Text(
"Delete",
style: TextStyle(color: Colors.red[400]),
),
),
],
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
class DisableMultiSelectButton extends ConsumerWidget {
const DisableMultiSelectButton({
Key? key,
required this.onPressed,
required this.selectedItemCount,
}) : super(key: key);
final Function onPressed;
final int selectedItemCount;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Positioned(
top: 0,
left: 0,
child: Padding(
padding: const EdgeInsets.only(left: 16.0, top: 46),
child: Material(
elevation: 20,
borderRadius: BorderRadius.circular(35),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(35),
color: Colors.grey[100],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: TextButton.icon(
onPressed: () {
onPressed();
},
icon: const Icon(Icons.close_rounded),
label: Text(
selectedItemCount.toString(),
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
)),
),
),
),
),
);
}
}

View File

@@ -1,8 +1,10 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:badges/badges.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/backup_state.model.dart'; import 'package:immich_mobile/shared/models/backup_state.model.dart';
@@ -12,94 +14,98 @@ class ImmichSliverAppBar extends ConsumerWidget {
const ImmichSliverAppBar({ const ImmichSliverAppBar({
Key? key, Key? key,
required this.imageGridGroup, required this.imageGridGroup,
this.onPopBack,
}) : super(key: key); }) : super(key: key);
final List<Widget> imageGridGroup; final List<Widget> imageGridGroup;
final Function? onPopBack;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final BackUpState _backupState = ref.watch(backupProvider); final BackUpState _backupState = ref.watch(backupProvider);
bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
return SliverPadding( return SliverAppBar(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 5), centerTitle: true,
sliver: SliverAppBar( floating: true,
centerTitle: true, pinned: false,
floating: true, snap: false,
pinned: false, backgroundColor: Colors.grey[200],
snap: false, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
backgroundColor: Colors.grey[200], leading: Builder(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), builder: (BuildContext context) {
leading: Builder( return IconButton(
builder: (BuildContext context) { icon: const Icon(Icons.account_circle_rounded),
return IconButton( onPressed: () {
icon: const Icon(Icons.account_circle_rounded), Scaffold.of(context).openDrawer();
onPressed: () { },
Scaffold.of(context).openDrawer(); tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
}, );
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip, },
);
},
),
title: Text(
'IMMICH',
style: GoogleFonts.snowburstOne(
textStyle: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: Theme.of(context).primaryColor,
),
),
),
actions: [
Stack(
alignment: AlignmentDirectional.center,
children: [
_backupState.backupProgress == BackUpProgressEnum.inProgress
? Positioned(
top: 10,
right: 12,
child: SizedBox(
height: 8,
width: 8,
child: CircularProgressIndicator(
strokeWidth: 1,
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
),
),
)
: Container(),
IconButton(
icon: const Icon(Icons.backup_rounded),
tooltip: 'Backup Controller',
onPressed: () async {
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
if (onPop == true) {
// Remove and force getting new widget again if there is not many widget on screen.
// Otherwise do nothing.
if (imageGridGroup.isNotEmpty && imageGridGroup.length < 20) {
print("Get more access");
ref.read(assetProvider.notifier).getMoreAsset();
} else if (imageGridGroup.isEmpty) {
print("get immich asset");
ref.read(assetProvider.notifier).getImmichAssets();
}
}
},
),
_backupState.backupProgress == BackUpProgressEnum.inProgress
? Positioned(
bottom: 5,
child: Text(
_backupState.backingUpAssetCount.toString(),
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
),
)
: Container()
],
),
],
), ),
title: Text(
'IMMICH',
style: GoogleFonts.snowburstOne(
textStyle: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: Theme.of(context).primaryColor,
),
),
),
actions: [
Stack(
alignment: AlignmentDirectional.center,
children: [
_backupState.backupProgress == BackUpProgressEnum.inProgress
? Positioned(
top: 10,
right: 12,
child: SizedBox(
height: 8,
width: 8,
child: CircularProgressIndicator(
strokeWidth: 1,
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
),
),
)
: Container(),
IconButton(
splashRadius: 25,
iconSize: 30,
icon: _isEnableAutoBackup
? const Icon(Icons.backup_rounded)
: Badge(
padding: const EdgeInsets.all(4),
elevation: 1,
position: BadgePosition.bottomEnd(bottom: -4, end: -4),
badgeColor: Colors.white,
badgeContent: const Icon(
Icons.cloud_off_rounded,
size: 8,
),
child: const Icon(Icons.backup_rounded)),
tooltip: 'Backup Controller',
onPressed: () async {
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
if (onPop == true) {
onPopBack!();
}
},
),
_backupState.backupProgress == BackUpProgressEnum.inProgress
? Positioned(
bottom: 5,
child: Text(
_backupState.backingUpAssetCount.toString(),
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
),
)
: Container()
],
),
],
); );
} }
} }

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class MonthlyTitleText extends StatelessWidget {
const MonthlyTitleText({
Key? key,
required this.isoDate,
}) : super(key: key);
final String isoDate;
@override
Widget build(BuildContext context) {
var monthTitleText = DateFormat('MMMM y').format(DateTime.parse(isoDate));
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 12.0, top: 32),
child: Text(
monthTitleText,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
);
}
}

View File

@@ -1,12 +1,9 @@
import 'package:auto_route/annotations.dart';
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/src/widgets/framework.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.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/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class ProfileDrawer extends ConsumerWidget { class ProfileDrawer extends ConsumerWidget {
const ProfileDrawer({Key? key}) : super(key: key); const ProfileDrawer({Key? key}) : super(key: key);
@@ -58,9 +55,9 @@ class ProfileDrawer extends ConsumerWidget {
), ),
onTap: () async { onTap: () async {
bool res = await ref.read(authenticationProvider.notifier).logout(); bool res = await ref.read(authenticationProvider.notifier).logout();
ref.read(assetProvider.notifier).clearAllAsset();
if (res) { if (res) {
ref.watch(assetProvider.notifier).clearAllAsset();
AutoRouter.of(context).popUntilRoot(); AutoRouter.of(context).popUntilRoot();
} }
}, },

View File

@@ -1,66 +1,122 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.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/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
class ThumbnailImage extends HookWidget { class ThumbnailImage extends HookConsumerWidget {
final ImmichAsset asset; final ImmichAsset asset;
const ThumbnailImage({Key? key, required this.asset}) : super(key: key); const ThumbnailImage({Key? key, required this.asset}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1); final cacheKey = useState(1);
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true'; '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
Widget _buildSelectionIcon(ImmichAsset asset) {
if (selectedAsset.contains(asset)) {
return Icon(
Icons.check_circle,
color: Theme.of(context).primaryColor,
);
} else {
return const Icon(
Icons.circle_outlined,
color: Colors.white,
);
}
}
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
if (asset.type == 'IMAGE') { if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length == 1) {
AutoRouter.of(context).push( ref.watch(homePageStateProvider.notifier).disableMultiSelect();
ImageViewerRoute( } else if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length > 1) {
imageUrl: ref.watch(homePageStateProvider.notifier).removeSingleSelectedItem(asset);
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false', } else if (isMultiSelectEnable && !selectedAsset.contains(asset)) {
heroTag: asset.id, ref.watch(homePageStateProvider.notifier).addSingleSelectedItem(asset);
thumbnailUrl: thumbnailRequestUrl,
),
);
} else { } else {
debugPrint("Navigate to video player"); if (asset.type == 'IMAGE') {
AutoRouter.of(context).push(
ImageViewerRoute(
imageUrl:
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
heroTag: asset.id,
thumbnailUrl: thumbnailRequestUrl,
asset: asset,
),
);
} else {
debugPrint("Navigate to video player");
AutoRouter.of(context).push( AutoRouter.of(context).push(
VideoViewerRoute( VideoViewerRoute(
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}', videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
), ),
); );
}
} }
}, },
onLongPress: () {}, onLongPress: () {
// Enable multi selecte function
ref.watch(homePageStateProvider.notifier).enableMultiSelect({asset});
HapticFeedback.heavyImpact();
},
child: Hero( child: Hero(
tag: asset.id, tag: asset.id,
child: CachedNetworkImage( child: Stack(
cacheKey: "${asset.id}-${cacheKey.value}", children: [
width: 300, Container(
height: 300, decoration: BoxDecoration(
memCacheHeight: asset.type == 'IMAGE' ? 250 : 400, border: isMultiSelectEnable && selectedAsset.contains(asset)
fit: BoxFit.cover, ? Border.all(color: Theme.of(context).primaryColorLight, width: 10)
imageUrl: thumbnailRequestUrl, : const Border(),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, ),
fadeInDuration: const Duration(milliseconds: 250), child: CachedNetworkImage(
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( cacheKey: "${asset.id}-${cacheKey.value}",
scale: 0.2, width: 300,
child: CircularProgressIndicator(value: downloadProgress.progress), height: 300,
), memCacheHeight: asset.type == 'IMAGE' ? 250 : 400,
errorWidget: (context, url, error) { fit: BoxFit.cover,
debugPrint("Error Loading Thumbnail Widget $error"); imageUrl: thumbnailRequestUrl,
cacheKey.value += 1; httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
return const Icon(Icons.error); fadeInDuration: const Duration(milliseconds: 250),
}, progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) {
debugPrint("Error Loading Thumbnail Widget $error");
cacheKey.value += 1;
return const Icon(Icons.error);
},
),
),
Container(
child: isMultiSelectEnable
? Padding(
padding: const EdgeInsets.all(3.0),
child: Align(
alignment: Alignment.topLeft,
child: _buildSelectionIcon(asset),
),
)
: Container(),
),
],
), ),
), ),
); );

View File

@@ -1,54 +1,64 @@
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:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
import 'package:immich_mobile/modules/home/ui/image_grid.dart'; import 'package:immich_mobile/modules/home/ui/image_grid.dart';
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart'; import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart'; import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:intl/intl.dart'; import 'package:sliver_tools/sliver_tools.dart';
class HomePage extends HookConsumerWidget { class HomePage extends HookConsumerWidget {
const HomePage({Key? key}) : super(key: key); const HomePage({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final ValueNotifier<bool> _showBackToTopBtn = useState(false);
ScrollController _scrollController = useScrollController(); ScrollController _scrollController = useScrollController();
List<ImmichAssetGroupByDate> _assetGroup = ref.watch(assetProvider);
List<ImmichAssetGroupByDate> assetGroup = ref.watch(assetProvider); List<Widget> _imageGridGroup = [];
List<Widget> imageGridGroup = []; var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
var homePageState = ref.watch(homePageStateProvider);
_scrollControllerCallback() { _scrollControllerCallback() {
var endOfPage = _scrollController.position.maxScrollExtent; var endOfPage = _scrollController.position.maxScrollExtent;
if (_scrollController.offset >= endOfPage - (endOfPage * 0.1) && !_scrollController.position.outOfRange) { if (_scrollController.offset >= endOfPage - (endOfPage * 0.1) && !_scrollController.position.outOfRange) {
ref.read(assetProvider.notifier).getMoreAsset(); ref.read(assetProvider.notifier).getOlderAsset();
}
if (_scrollController.offset >= 400) {
_showBackToTopBtn.value = true;
} else {
_showBackToTopBtn.value = false;
} }
} }
useEffect(() { useEffect(() {
ref.read(assetProvider.notifier).getImmichAssets(); ref.read(assetProvider.notifier).getAllAssets();
_scrollController.addListener(_scrollControllerCallback); _scrollController.addListener(_scrollControllerCallback);
return () { return () {
_scrollController.removeListener(_scrollControllerCallback); _scrollController.removeListener(_scrollControllerCallback);
}; };
}, []); }, []);
Widget _buildBody() { onPopBackFromBackupPage() {
if (assetGroup.isNotEmpty) { ref.read(assetProvider.notifier).getNewAsset();
String lastGroupDate = assetGroup[0].date; // Remove and force getting new widget again if there is not many widget on screen.
// Otherwise do nothing.
for (var group in assetGroup) { if (_imageGridGroup.isNotEmpty && _imageGridGroup.length < 20) {
ref.read(assetProvider.notifier).getOlderAsset();
} else if (_imageGridGroup.isEmpty) {
ref.read(assetProvider.notifier).getAllAssets();
}
}
Widget _buildBody() {
if (_assetGroup.isNotEmpty) {
String lastGroupDate = _assetGroup[0].date;
for (var group in _assetGroup) {
var dateTitle = group.date; var dateTitle = group.date;
var assetGroup = group.assets; var assetGroup = group.assets;
@@ -56,19 +66,25 @@ class HomePage extends HookConsumerWidget {
int? previousMonth = DateTime.tryParse(lastGroupDate)?.month; int? previousMonth = DateTime.tryParse(lastGroupDate)?.month;
// Add Monthly Title Group if started at the beginning of the month // Add Monthly Title Group if started at the beginning of the month
if ((currentMonth! - previousMonth!) != 0) {
imageGridGroup.add( if (currentMonth != null && previousMonth != null) {
MonthlyTitleText(isoDate: dateTitle), if ((currentMonth - previousMonth) != 0) {
); _imageGridGroup.add(
MonthlyTitleText(isoDate: dateTitle),
);
}
} }
// Add Daily Title Group // Add Daily Title Group
imageGridGroup.add( _imageGridGroup.add(
DailyTitleText(isoDate: dateTitle), DailyTitleText(
isoDate: dateTitle,
assetGroup: assetGroup,
),
); );
// Add Image Group // Add Image Group
imageGridGroup.add( _imageGridGroup.add(
ImageGrid(assetGroup: assetGroup), ImageGrid(assetGroup: assetGroup),
); );
// //
@@ -77,90 +93,51 @@ class HomePage extends HookConsumerWidget {
} }
return SafeArea( return SafeArea(
child: DraggableScrollbar.semicircle( bottom: !isMultiSelectEnable,
backgroundColor: Theme.of(context).primaryColor, top: !isMultiSelectEnable,
controller: _scrollController, child: Stack(
heightScrollThumb: 48.0, children: [
child: CustomScrollView( DraggableScrollbar.semicircle(
controller: _scrollController, backgroundColor: Theme.of(context).primaryColor,
slivers: [ controller: _scrollController,
ImmichSliverAppBar(imageGridGroup: imageGridGroup), heightScrollThumb: 48.0,
...imageGridGroup, child: CustomScrollView(
], controller: _scrollController,
), slivers: [
SliverAnimatedSwitcher(
child: isMultiSelectEnable
? const SliverToBoxAdapter(
child: SizedBox(
height: 70,
child: null,
),
)
: ImmichSliverAppBar(
imageGridGroup: _imageGridGroup,
onPopBack: onPopBackFromBackupPage,
),
duration: const Duration(milliseconds: 350),
),
..._imageGridGroup
],
),
),
isMultiSelectEnable
? DisableMultiSelectButton(
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
selectedItemCount: homePageState.selectedItems.length,
)
: Container(),
isMultiSelectEnable ? const ControlBottomAppBar() : Container(),
],
), ),
); );
} }
return Scaffold( return Scaffold(
// key: _scaffoldKey,
drawer: const ProfileDrawer(), drawer: const ProfileDrawer(),
body: _buildBody(), body: _buildBody(),
); );
} }
} }
class MonthlyTitleText extends StatelessWidget {
const MonthlyTitleText({
Key? key,
required this.isoDate,
}) : super(key: key);
final String isoDate;
@override
Widget build(BuildContext context) {
var monthTitleText = DateFormat('MMMM, y').format(DateTime.parse(isoDate));
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 10.0, top: 32),
child: Text(
monthTitleText,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
);
}
}
class DailyTitleText extends StatelessWidget {
const DailyTitleText({
Key? key,
required this.isoDate,
}) : super(key: key);
final String isoDate;
@override
Widget build(BuildContext context) {
var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(isoDate).year;
var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy';
var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 24.0, bottom: 24.0, left: 3.0),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 8.0, bottom: 5.0, top: 5.0),
child: Text(
dateText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
],
),
),
);
}
}

View File

@@ -11,7 +11,7 @@ import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/shared/models/device_info.model.dart'; import 'package:immich_mobile/shared/models/device_info.model.dart';
class AuthenticationNotifier extends StateNotifier<AuthenticationState> { class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
AuthenticationNotifier() AuthenticationNotifier(this.ref)
: super( : super(
AuthenticationState( AuthenticationState(
deviceId: "", deviceId: "",
@@ -31,6 +31,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
), ),
); );
final Ref ref;
final DeviceInfoService _deviceInfoService = DeviceInfoService(); final DeviceInfoService _deviceInfoService = DeviceInfoService();
final BackupService _backupService = BackupService(); final BackupService _backupService = BackupService();
final NetworkService _networkService = NetworkService(); final NetworkService _networkService = NetworkService();
@@ -126,5 +127,5 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
} }
final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) { final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
return AuthenticationNotifier(); return AuthenticationNotifier(ref);
}); });

View File

@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/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/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
class LoginForm extends HookConsumerWidget { class LoginForm extends HookConsumerWidget {
@@ -13,7 +15,7 @@ class LoginForm extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final usernameController = useTextEditingController(text: 'testuser@email.com'); final usernameController = useTextEditingController(text: 'testuser@email.com');
final passwordController = useTextEditingController(text: 'password'); final passwordController = useTextEditingController(text: 'password');
final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283'); final serverEndpointController = useTextEditingController(text: 'http://192.168.1.204:2283');
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
@@ -110,16 +112,21 @@ class LoginButton extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton( return ElevatedButton(
onPressed: () async { onPressed: () async {
// This will remove current cache asset state of previous user login.
ref.watch(assetProvider.notifier).clearAllAsset();
var isAuthenicated = await ref var isAuthenicated = await ref
.read(authenticationProvider.notifier) .read(authenticationProvider.notifier)
.login(emailController.text, passwordController.text, serverEndpointController.text); .login(emailController.text, passwordController.text, serverEndpointController.text);
if (isAuthenicated) { if (isAuthenicated) {
// Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup();
AutoRouter.of(context).pushNamed("/home-page"); AutoRouter.of(context).pushNamed("/home-page");
} else { } else {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: "Error logging you in, check server url, emald and password!", msg: "Error logging you in, check server url, email and password!",
toastType: ToastType.error); toastType: ToastType.error);
} }
}, },

View File

@@ -1,7 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/login/ui/login_form.dart'; import 'package:immich_mobile/modules/login/ui/login_form.dart';
class LoginPage extends HookConsumerWidget { class LoginPage extends HookConsumerWidget {

View File

@@ -3,8 +3,9 @@ import 'package:flutter/widgets.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart'; import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/home/views/home_page.dart'; import 'package:immich_mobile/modules/home/views/home_page.dart';
import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/views/backup_controller_page.dart'; import 'package:immich_mobile/shared/views/backup_controller_page.dart';
import 'package:immich_mobile/shared/views/image_viewer_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/shared/views/video_viewer_page.dart'; import 'package:immich_mobile/shared/views/video_viewer_page.dart';
part 'router.gr.dart'; part 'router.gr.dart';

View File

@@ -41,7 +41,8 @@ class _$AppRouter extends RootStackRouter {
key: args.key, key: args.key,
imageUrl: args.imageUrl, imageUrl: args.imageUrl,
heroTag: args.heroTag, heroTag: args.heroTag,
thumbnailUrl: args.thumbnailUrl)); thumbnailUrl: args.thumbnailUrl,
asset: args.asset));
}, },
VideoViewerRoute.name: (routeData) { VideoViewerRoute.name: (routeData) {
final args = routeData.argsAs<VideoViewerRouteArgs>(); final args = routeData.argsAs<VideoViewerRouteArgs>();
@@ -96,14 +97,16 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
{Key? key, {Key? key,
required String imageUrl, required String imageUrl,
required String heroTag, required String heroTag,
required String thumbnailUrl}) required String thumbnailUrl,
required ImmichAsset asset})
: super(ImageViewerRoute.name, : super(ImageViewerRoute.name,
path: '/image-viewer-page', path: '/image-viewer-page',
args: ImageViewerRouteArgs( args: ImageViewerRouteArgs(
key: key, key: key,
imageUrl: imageUrl, imageUrl: imageUrl,
heroTag: heroTag, heroTag: heroTag,
thumbnailUrl: thumbnailUrl)); thumbnailUrl: thumbnailUrl,
asset: asset));
static const String name = 'ImageViewerRoute'; static const String name = 'ImageViewerRoute';
} }
@@ -113,7 +116,8 @@ class ImageViewerRouteArgs {
{this.key, {this.key,
required this.imageUrl, required this.imageUrl,
required this.heroTag, required this.heroTag,
required this.thumbnailUrl}); required this.thumbnailUrl,
required this.asset});
final Key? key; final Key? key;
@@ -123,9 +127,11 @@ class ImageViewerRouteArgs {
final String thumbnailUrl; final String thumbnailUrl;
final ImmichAsset asset;
@override @override
String toString() { String toString() {
return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl}'; return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl, asset: $asset}';
} }
} }

View File

@@ -0,0 +1,187 @@
import 'dart:convert';
class ImmichExif {
final int? id;
final String? assetId;
final String? make;
final String? model;
final String? imageName;
final int? exifImageWidth;
final int? exifImageHeight;
final int? fileSizeInByte;
final String? orientation;
final String? dateTimeOriginal;
final String? modifyDate;
final String? lensModel;
final double? fNumber;
final double? focalLength;
final int? iso;
final double? exposureTime;
final double? latitude;
final double? longitude;
ImmichExif({
this.id,
this.assetId,
this.make,
this.model,
this.imageName,
this.exifImageWidth,
this.exifImageHeight,
this.fileSizeInByte,
this.orientation,
this.dateTimeOriginal,
this.modifyDate,
this.lensModel,
this.fNumber,
this.focalLength,
this.iso,
this.exposureTime,
this.latitude,
this.longitude,
});
ImmichExif copyWith({
int? id,
String? assetId,
String? make,
String? model,
String? imageName,
int? exifImageWidth,
int? exifImageHeight,
int? fileSizeInByte,
String? orientation,
String? dateTimeOriginal,
String? modifyDate,
String? lensModel,
double? fNumber,
double? focalLength,
int? iso,
double? exposureTime,
double? latitude,
double? longitude,
}) {
return ImmichExif(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
make: make ?? this.make,
model: model ?? this.model,
imageName: imageName ?? this.imageName,
exifImageWidth: exifImageWidth ?? this.exifImageWidth,
exifImageHeight: exifImageHeight ?? this.exifImageHeight,
fileSizeInByte: fileSizeInByte ?? this.fileSizeInByte,
orientation: orientation ?? this.orientation,
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
modifyDate: modifyDate ?? this.modifyDate,
lensModel: lensModel ?? this.lensModel,
fNumber: fNumber ?? this.fNumber,
focalLength: focalLength ?? this.focalLength,
iso: iso ?? this.iso,
exposureTime: exposureTime ?? this.exposureTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'assetId': assetId,
'make': make,
'model': model,
'imageName': imageName,
'exifImageWidth': exifImageWidth,
'exifImageHeight': exifImageHeight,
'fileSizeInByte': fileSizeInByte,
'orientation': orientation,
'dateTimeOriginal': dateTimeOriginal,
'modifyDate': modifyDate,
'lensModel': lensModel,
'fNumber': fNumber,
'focalLength': focalLength,
'iso': iso,
'exposureTime': exposureTime,
'latitude': latitude,
'longitude': longitude,
};
}
factory ImmichExif.fromMap(Map<String, dynamic> map) {
return ImmichExif(
id: map['id']?.toInt(),
assetId: map['assetId'],
make: map['make'],
model: map['model'],
imageName: map['imageName'],
exifImageWidth: map['exifImageWidth']?.toInt(),
exifImageHeight: map['exifImageHeight']?.toInt(),
fileSizeInByte: map['fileSizeInByte']?.toInt(),
orientation: map['orientation'],
dateTimeOriginal: map['dateTimeOriginal'],
modifyDate: map['modifyDate'],
lensModel: map['lensModel'],
fNumber: map['fNumber']?.toDouble(),
focalLength: map['focalLength']?.toDouble(),
iso: map['iso']?.toInt(),
exposureTime: map['exposureTime']?.toDouble(),
latitude: map['latitude']?.toDouble(),
longitude: map['longitude']?.toDouble(),
);
}
String toJson() => json.encode(toMap());
factory ImmichExif.fromJson(String source) => ImmichExif.fromMap(json.decode(source));
@override
String toString() {
return 'ImmichExif(id: $id, assetId: $assetId, make: $make, model: $model, imageName: $imageName, exifImageWidth: $exifImageWidth, exifImageHeight: $exifImageHeight, fileSizeInByte: $fileSizeInByte, orientation: $orientation, dateTimeOriginal: $dateTimeOriginal, modifyDate: $modifyDate, lensModel: $lensModel, fNumber: $fNumber, focalLength: $focalLength, iso: $iso, exposureTime: $exposureTime, latitude: $latitude, longitude: $longitude)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImmichExif &&
other.id == id &&
other.assetId == assetId &&
other.make == make &&
other.model == model &&
other.imageName == imageName &&
other.exifImageWidth == exifImageWidth &&
other.exifImageHeight == exifImageHeight &&
other.fileSizeInByte == fileSizeInByte &&
other.orientation == orientation &&
other.dateTimeOriginal == dateTimeOriginal &&
other.modifyDate == modifyDate &&
other.lensModel == lensModel &&
other.fNumber == fNumber &&
other.focalLength == focalLength &&
other.iso == iso &&
other.exposureTime == exposureTime &&
other.latitude == latitude &&
other.longitude == longitude;
}
@override
int get hashCode {
return id.hashCode ^
assetId.hashCode ^
make.hashCode ^
model.hashCode ^
imageName.hashCode ^
exifImageWidth.hashCode ^
exifImageHeight.hashCode ^
fileSizeInByte.hashCode ^
orientation.hashCode ^
dateTimeOriginal.hashCode ^
modifyDate.hashCode ^
lensModel.hashCode ^
fNumber.hashCode ^
focalLength.hashCode ^
iso.hashCode ^
exposureTime.hashCode ^
latitude.hashCode ^
longitude.hashCode;
}
}

View File

@@ -0,0 +1,133 @@
import 'dart:convert';
import 'package:immich_mobile/shared/models/exif.model.dart';
class ImmichAssetWithExif {
final String id;
final String deviceAssetId;
final String userId;
final String deviceId;
final String type;
final String createdAt;
final String modifiedAt;
final String originalPath;
final bool isFavorite;
final String? duration;
final ImmichExif? exifInfo;
ImmichAssetWithExif({
required this.id,
required this.deviceAssetId,
required this.userId,
required this.deviceId,
required this.type,
required this.createdAt,
required this.modifiedAt,
required this.originalPath,
required this.isFavorite,
this.duration,
this.exifInfo,
});
ImmichAssetWithExif copyWith({
String? id,
String? deviceAssetId,
String? userId,
String? deviceId,
String? type,
String? createdAt,
String? modifiedAt,
String? originalPath,
bool? isFavorite,
String? duration,
ImmichExif? exifInfo,
}) {
return ImmichAssetWithExif(
id: id ?? this.id,
deviceAssetId: deviceAssetId ?? this.deviceAssetId,
userId: userId ?? this.userId,
deviceId: deviceId ?? this.deviceId,
type: type ?? this.type,
createdAt: createdAt ?? this.createdAt,
modifiedAt: modifiedAt ?? this.modifiedAt,
originalPath: originalPath ?? this.originalPath,
isFavorite: isFavorite ?? this.isFavorite,
duration: duration ?? this.duration,
exifInfo: exifInfo ?? this.exifInfo,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'deviceAssetId': deviceAssetId,
'userId': userId,
'deviceId': deviceId,
'type': type,
'createdAt': createdAt,
'modifiedAt': modifiedAt,
'originalPath': originalPath,
'isFavorite': isFavorite,
'duration': duration,
'exifInfo': exifInfo?.toMap(),
};
}
factory ImmichAssetWithExif.fromMap(Map<String, dynamic> map) {
return ImmichAssetWithExif(
id: map['id'] ?? '',
deviceAssetId: map['deviceAssetId'] ?? '',
userId: map['userId'] ?? '',
deviceId: map['deviceId'] ?? '',
type: map['type'] ?? '',
createdAt: map['createdAt'] ?? '',
modifiedAt: map['modifiedAt'] ?? '',
originalPath: map['originalPath'] ?? '',
isFavorite: map['isFavorite'] ?? false,
duration: map['duration'],
exifInfo: map['exifInfo'] != null ? ImmichExif.fromMap(map['exifInfo']) : null,
);
}
String toJson() => json.encode(toMap());
factory ImmichAssetWithExif.fromJson(String source) => ImmichAssetWithExif.fromMap(json.decode(source));
@override
String toString() {
return 'ImmichAssetWithExif(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, originalPath: $originalPath, isFavorite: $isFavorite, duration: $duration, exifInfo: $exifInfo)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImmichAssetWithExif &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.userId == userId &&
other.deviceId == deviceId &&
other.type == type &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.originalPath == originalPath &&
other.isFavorite == isFavorite &&
other.duration == duration &&
other.exifInfo == exifInfo;
}
@override
int get hashCode {
return id.hashCode ^
deviceAssetId.hashCode ^
userId.hashCode ^
deviceId.hashCode ^
type.hashCode ^
createdAt.hashCode ^
modifiedAt.hashCode ^
originalPath.hashCode ^
isFavorite.hashCode ^
duration.hashCode ^
exifInfo.hashCode;
}
}

View File

@@ -1,6 +1,9 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.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/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart'; import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:immich_mobile/shared/models/backup_state.model.dart'; import 'package:immich_mobile/shared/models/backup_state.model.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart'; import 'package:immich_mobile/shared/models/server_info.model.dart';
@@ -8,7 +11,7 @@ import 'package:immich_mobile/shared/services/backup.service.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
class BackupNotifier extends StateNotifier<BackUpState> { class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier() BackupNotifier(this.ref)
: super( : super(
BackUpState( BackUpState(
backupProgress: BackUpProgressEnum.idle, backupProgress: BackUpProgressEnum.idle,
@@ -29,6 +32,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
), ),
); );
final Ref ref;
final BackupService _backupService = BackupService(); final BackupService _backupService = BackupService();
final ServerInfoService _serverInfoService = ServerInfoService(); final ServerInfoService _serverInfoService = ServerInfoService();
@@ -96,7 +100,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
void cancelBackup() { void cancelBackup() {
state.cancelToken.cancel('Cancel Backup'); state.cancelToken.cancel('Cancel Backup');
state = state.copyWith(backupProgress: BackUpProgressEnum.idle); state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
} }
void _onAssetUploaded() { void _onAssetUploaded() {
@@ -130,8 +134,38 @@ class BackupNotifier extends StateNotifier<BackUpState> {
), ),
); );
} }
void resumeBackup() {
debugPrint("[resumeBackup]");
var authState = ref.read(authenticationProvider);
// Check if user is login
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
// User has been logged out return
if (accessKey == null || !authState.isAuthenticated) {
debugPrint("[resumeBackup] not authenticated - abort");
return;
}
// Check if this device is enable backup by the user
if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
// check if backup is alreayd in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
debugPrint("[resumeBackup] Backup is already in progress - abort");
return;
}
// Run backup
debugPrint("[resumeBackup] Start back up");
startBackupProcess();
}
debugPrint("[resumeBackup] User disables auto backup");
return;
}
} }
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) { final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(); return BackupNotifier(ref);
}); });

View File

@@ -37,20 +37,12 @@ class BackupService {
for (var entity in assetList) { for (var entity in assetList) {
try { try {
if (entity.type == AssetType.video) { if (entity.type == AssetType.video) {
file = await entity.file; file = await entity.originFile;
} else { } else {
file = await entity.file.timeout(const Duration(seconds: 5)); file = await entity.originFile.timeout(const Duration(seconds: 5));
} }
if (file != null) { if (file != null) {
// reading exif
// var exifData = await readExifFromFile(file);
// for (String key in exifData.keys) {
// debugPrint("- $key (${exifData[key]?.tagType}): ${exifData[key]}");
// }
// debugPrint("------------------");
String originalFileName = await entity.titleAsync; String originalFileName = await entity.titleAsync;
String fileNameWithoutPath = originalFileName.toString().split(".")[0]; String fileNameWithoutPath = originalFileName.toString().split(".")[0];
var fileExtension = p.extension(file.path); var fileExtension = p.extension(file.path);

View File

@@ -1,64 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
class ImageViewerPage extends StatelessWidget {
final String imageUrl;
final String heroTag;
final String thumbnailUrl;
const ImageViewerPage({Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl})
: super(key: key);
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
toolbarHeight: 60,
backgroundColor: Colors.black,
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop();
},
icon: const Icon(Icons.arrow_back_ios)),
),
body: Dismissible(
direction: DismissDirection.vertical,
onDismissed: (_) {
AutoRouter.of(context).pop();
},
key: Key(heroTag),
child: Center(
child: Hero(
tag: heroTag,
child: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
errorWidget: (context, url, error) => const Icon(Icons.error),
placeholder: (context, url) {
return CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
placeholderFadeInDuration: const Duration(milliseconds: 0),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) => const Icon(Icons.error),
);
},
),
),
),
),
);
}
}

View File

@@ -1,5 +1,6 @@
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/services.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:chewie/chewie.dart'; import 'package:chewie/chewie.dart';
@@ -17,6 +18,7 @@ class VideoViewerPage extends StatelessWidget {
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
appBar: AppBar( appBar: AppBar(
systemOverlayStyle: SystemUiOverlayStyle.light,
backgroundColor: Colors.black, backgroundColor: Colors.black,
leading: IconButton( leading: IconButton(
onPressed: () { onPressed: () {
@@ -24,7 +26,7 @@ class VideoViewerPage extends StatelessWidget {
}, },
icon: const Icon(Icons.arrow_back_ios)), icon: const Icon(Icons.arrow_back_ios)),
), ),
body: Center( body: SafeArea(
child: VideoThumbnailPlayer( child: VideoThumbnailPlayer(
url: videoUrl, url: videoUrl,
jwtToken: jwtToken, jwtToken: jwtToken,
@@ -64,7 +66,6 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
setState(() {}); setState(() {});
} catch (e) { } catch (e) {
debugPrint("ERROR initialize video player"); debugPrint("ERROR initialize video player");
print(e);
} }
} }

View File

@@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
class FileHelper { class FileHelper {
static getMimeType(String filePath) { static getMimeType(String filePath) {
debugPrint(filePath);
var fileExtension = p.extension(filePath).split(".")[1]; var fileExtension = p.extension(filePath).split(".")[1];
switch (fileExtension.toLowerCase()) { switch (fileExtension.toLowerCase()) {
@@ -28,6 +26,12 @@ class FileHelper {
case 'avi': case 'avi':
return {"type": "video", "subType": "x-msvideo"}; return {"type": "video", "subType": "x-msvideo"};
case 'heic':
return {"type": "image", "subType": "heic"};
case 'heif':
return {"type": "image", "subType": "heif"};
default: default:
return {"type": "unsupport", "subType": "unsupport"}; return {"type": "unsupport", "subType": "unsupport"};
} }

View File

@@ -2,7 +2,7 @@ build:
flutter packages pub run build_runner build flutter packages pub run build_runner build
watch: watch:
flutter packages pub run build_runner watch flutter packages pub run build_runner watch --delete-conflicting-outputs
create_app_icon: create_app_icon:
flutter pub run flutter_launcher_icons:main flutter pub run flutter_launcher_icons:main

View File

@@ -50,6 +50,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.1" version: "3.2.1"
badges:
dependency: "direct main"
description:
name: badges
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -513,13 +520,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.12.11" version: "0.12.11"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.3"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -639,6 +639,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.10" version: "1.3.10"
photo_view:
dependency: "direct main"
description:
name: photo_view
url: "https://pub.dartlang.org"
source: hosted
version: "0.13.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -721,6 +728,13 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.99"
sliver_tools:
dependency: "direct main"
description:
name: sliver_tools
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.5"
source_gen: source_gen:
dependency: transitive dependency: transitive
description: description:
@@ -818,7 +832,7 @@ packages:
name: test_api name: test_api
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.8" version: "0.4.3"
timing: timing:
dependency: transitive dependency: transitive
description: description:

View File

@@ -30,6 +30,9 @@ dependencies:
fluttertoast: ^8.0.8 fluttertoast: ^8.0.8
video_player: ^2.2.18 video_player: ^2.2.18
chewie: ^1.2.2 chewie: ^1.2.2
sliver_tools: ^0.2.5
badges: ^2.0.2
photo_view: ^0.13.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -1,80 +1,41 @@
FROM ubuntu:20.04 AS development ##################################
# DEVELOPMENT
##################################
FROM node:16-alpine3.14 AS development
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY package.json yarn.lock ./ COPY package.json package-lock.json ./
RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \ RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
build-essential \
curl \
git-core \
iputils-ping \
pkg-config \
rsync \
software-properties-common \
unzip \
wget \
ffmpeg
# Install NodeJS RUN npm install
RUN curl --silent --location https://deb.nodesource.com/setup_14.x | bash -
RUN apt-get install --yes nodejs
RUN npm i -g yarn
RUN yarn install
COPY . . COPY . .
RUN yarn build RUN npm run build
# Clean up commands #################################
RUN apt-get autoremove -y && apt-get clean && \ # PRODUCTION
rm -rf /usr/local/src/* #################################
FROM node:16-alpine3.14 AS production
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/*
FROM ubuntu:20.04 as production
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
ARG NODE_ENV=production ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV} ENV NODE_ENV=${NODE_ENV}
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY package.json yarn.lock ./ COPY package.json package-lock.json ./
RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \ RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
build-essential \
curl \
git-core \
iputils-ping \
pkg-config \
rsync \
software-properties-common \
unzip \
wget \
ffmpeg
# Install NodeJS RUN npm install --only=production
RUN curl --silent --location https://deb.nodesource.com/setup_14.x | bash -
RUN apt-get install --yes nodejs
RUN npm i -g yarn
RUN yarn install --only=production
COPY . . COPY . .
COPY --from=development /usr/src/app/dist ./dist COPY --from=development /usr/src/app/dist ./dist
# Clean up commands
RUN apt-get autoremove -y && apt-get clean && \
rm -rf /usr/local/src/*
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/*
CMD ["node", "dist/main"] CMD ["node", "dist/main"]

View File

@@ -1,63 +0,0 @@
##################################
# DEVELOPMENT
##################################
FROM node:16-bullseye-slim AS development
ARG DEBIAN_FRONTEND=noninteractive
WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN apt-get update
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
RUN npm i -g yarn --force
RUN yarn install
COPY . .
RUN yarn build
# Clean up commands
RUN apt-get autoremove -y && apt-get clean && \
rm -rf /usr/local/src/*
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/*
##################################
# PRODUCTION
##################################
FROM node:16-bullseye-slim as production
ARG DEBIAN_FRONTEND=noninteractive
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN apt-get update
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
RUN npm i -g yarn --force
RUN yarn install --only=production
COPY . .
COPY --from=development /usr/src/app/dist ./dist
# Clean up commands
RUN apt-get autoremove -y && apt-get clean && \
rm -rf /usr/local/src/*
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/*
CMD ["node", "dist/main"]

View File

@@ -1,13 +1 @@
# IMMICH - Server # Immich Server- NestJs
A self-hosted solution for mobile backup and viewing images/videos.
# Requesquisite
There is a tensorflow module running in the server so some package will be needed when building the Node's modules
```bash
$ apt-get install make cmake gcc g++
```

View File

@@ -8,8 +8,8 @@ services:
build: build:
context: . context: .
target: development target: development
dockerfile: ./Dockerfile-minimal dockerfile: ./Dockerfile
command: yarn start:dev command: npm run start:dev
ports: ports:
- "3000:3000" - "3000:3000"
# expose: # expose:

18118
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -31,16 +31,12 @@
"@nestjs/platform-express": "^8.0.0", "@nestjs/platform-express": "^8.0.0",
"@nestjs/platform-fastify": "^8.2.6", "@nestjs/platform-fastify": "^8.2.6",
"@nestjs/typeorm": "^8.0.3", "@nestjs/typeorm": "^8.0.3",
"@tensorflow-models/coco-ssd": "^2.2.2",
"@tensorflow/tfjs": "^3.13.0",
"@tensorflow/tfjs-converter": "^3.13.0",
"@tensorflow/tfjs-core": "^3.13.0",
"@tensorflow/tfjs-node": "^3.13.0",
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",
"bull": "^4.4.0", "bull": "^4.4.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"dotenv": "^14.2.0", "dotenv": "^14.2.0",
"exifr": "^7.1.3",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"joi": "^17.5.0", "joi": "^17.5.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@@ -50,7 +46,7 @@
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^7.2.0", "rxjs": "^7.2.0",
"sharp": "^0.29.3", "sharp": "0.28",
"systeminformation": "^5.11.0", "systeminformation": "^5.11.0",
"typeorm": "^0.2.41" "typeorm": "^0.2.41"
}, },

View File

@@ -29,6 +29,8 @@ import { Response as Res } from 'express';
import { promisify } from 'util'; import { promisify } from 'util';
import { stat } from 'fs'; import { stat } from 'fs';
import { pipeline } from 'stream'; import { pipeline } from 'stream';
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@@ -36,8 +38,9 @@ const fileInfo = promisify(stat);
@Controller('asset') @Controller('asset')
export class AssetController { export class AssetController {
constructor( constructor(
private readonly assetService: AssetService, private assetService: AssetService,
private readonly assetOptimizeService: AssetOptimizeService, private assetOptimizeService: AssetOptimizeService,
private backgroundTaskService: BackgroundTaskService,
) {} ) {}
@Post('upload') @Post('upload')
@@ -52,6 +55,7 @@ export class AssetController {
if (savedAsset && savedAsset.type == AssetType.IMAGE) { if (savedAsset && savedAsset.type == AssetType.IMAGE) {
await this.assetOptimizeService.resizeImage(savedAsset); await this.assetOptimizeService.resizeImage(savedAsset);
await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size);
} }
if (savedAsset && savedAsset.type == AssetType.VIDEO) { if (savedAsset && savedAsset.type == AssetType.VIDEO) {
@@ -117,7 +121,6 @@ export class AssetController {
} }
/** Sending Partial Content With HTTP Code 206 */ /** Sending Partial Content With HTTP Code 206 */
console.log('Sendinf file with type ', asset.mimeType);
res.status(206).set({ res.status(206).set({
'Content-Range': `bytes ${start}-${end}/${size}`, 'Content-Range': `bytes ${start}-${end}/${size}`,
@@ -141,6 +144,11 @@ export class AssetController {
console.log('SHOULD NOT BE HERE'); console.log('SHOULD NOT BE HERE');
} }
@Get('/new')
async getNewAssets(@GetAuthUser() authUser: AuthUserDto, @Query(ValidationPipe) query: GetNewAssetQueryDto) {
return await this.assetService.getNewAssets(authUser, query.latestDate);
}
@Get('/all') @Get('/all')
async getAllAssets(@GetAuthUser() authUser: AuthUserDto, @Query(ValidationPipe) query: GetAllAssetQueryDto) { async getAllAssets(@GetAuthUser() authUser: AuthUserDto, @Query(ValidationPipe) query: GetAllAssetQueryDto) {
return await this.assetService.getAllAssets(authUser, query); return await this.assetService.getAllAssets(authUser, query);
@@ -150,4 +158,9 @@ export class AssetController {
async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) { async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) {
return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId); return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
} }
@Get('/assetById/:assetId')
async getAssetById(@GetAuthUser() authUser: AuthUserDto, @Param('assetId') assetId) {
return this.assetService.getAssetById(authUser, assetId);
}
} }

View File

@@ -6,6 +6,8 @@ import { AssetEntity } from './entities/asset.entity';
import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module'; import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module';
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service'; import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
@Module({ @Module({
imports: [ imports: [
@@ -18,7 +20,7 @@ import { BullModule } from '@nestjs/bull';
}, },
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'machine-learning', name: 'background-task',
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@@ -27,9 +29,10 @@ import { BullModule } from '@nestjs/bull';
}), }),
TypeOrmModule.forFeature([AssetEntity]), TypeOrmModule.forFeature([AssetEntity]),
ImageOptimizeModule, ImageOptimizeModule,
BackgroundTaskModule,
], ],
controllers: [AssetController], controllers: [AssetController],
providers: [AssetService, AssetOptimizeService], providers: [AssetService, AssetOptimizeService, BackgroundTaskService],
exports: [], exports: [],
}) })
export class AssetModule {} export class AssetModule {}

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { MoreThan, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
import { UpdateAssetDto } from './dto/update-asset.dto'; import { UpdateAssetDto } from './dto/update-asset.dto';
@@ -53,8 +53,6 @@ export class AssetService {
} }
public async getAllAssets(authUser: AuthUserDto, query: GetAllAssetQueryDto): Promise<GetAllAssetReponseDto> { public async getAllAssets(authUser: AuthUserDto, query: GetAllAssetQueryDto): Promise<GetAllAssetReponseDto> {
// Each page will take 100 images.
try { try {
const assets = await this.assetRepository const assets = await this.assetRepository
.createQueryBuilder('a') .createQueryBuilder('a')
@@ -63,7 +61,7 @@ export class AssetService {
lastQueryCreatedAt: query.nextPageKey || new Date().toISOString(), lastQueryCreatedAt: query.nextPageKey || new Date().toISOString(),
}) })
.orderBy('a."createdAt"::date', 'DESC') .orderBy('a."createdAt"::date', 'DESC')
// .take(500) .take(5000)
.getMany(); .getMany();
if (assets.length > 0) { if (assets.length > 0) {
@@ -102,4 +100,26 @@ export class AssetService {
return rows[0] as AssetEntity; return rows[0] as AssetEntity;
} }
public async getNewAssets(authUser: AuthUserDto, latestDate: string) {
return await this.assetRepository.find({
where: {
userId: authUser.id,
createdAt: MoreThan(latestDate),
},
order: {
createdAt: 'ASC', // ASC order to add existed asset the latest group first before creating a new date group.
},
});
}
public async getAssetById(authUser: AuthUserDto, assetId: string) {
return await this.assetRepository.findOne({
where: {
userId: authUser.id,
id: assetId,
},
relations: ['exifInfo'],
});
}
} }

View File

@@ -0,0 +1,48 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
export class CreateExifDto {
@IsNotEmpty()
assetId: string;
@IsOptional()
make: string;
@IsOptional()
model: string;
@IsOptional()
imageName: string;
@IsOptional()
exifImageWidth: number;
@IsOptional()
exifImageHeight: number;
@IsOptional()
fileSizeInByte: number;
@IsOptional()
orientation: string;
@IsOptional()
dateTimeOriginal: Date;
@IsOptional()
modifiedDate: Date;
@IsOptional()
lensModel: string;
@IsOptional()
fNumber: number;
@IsOptional()
focalLenght: number;
@IsOptional()
iso: number;
@IsOptional()
exposureTime: number;
}

View File

@@ -1,6 +1,6 @@
import { IsNotEmpty } from 'class-validator'; import { IsNotEmpty } from 'class-validator';
class GetAssetDto { export class GetAssetDto {
@IsNotEmpty() @IsNotEmpty()
deviceId: string; deviceId: string;
} }

View File

@@ -0,0 +1,6 @@
import { IsNotEmpty } from 'class-validator';
export class GetNewAssetQueryDto {
@IsNotEmpty()
latestDate: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateExifDto } from './create-exif.dto';
export class UpdateExifDto extends PartialType(CreateExifDto) {}

View File

@@ -1,4 +1,5 @@
import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm'; import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { ExifEntity } from './exif.entity';
@Entity('assets') @Entity('assets')
@Unique(['deviceAssetId', 'userId', 'deviceId']) @Unique(['deviceAssetId', 'userId', 'deviceId'])
@@ -38,6 +39,9 @@ export class AssetEntity {
@Column({ nullable: true }) @Column({ nullable: true })
duration: string; duration: string;
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
exifInfo: ExifEntity;
} }
export enum AssetType { export enum AssetType {

View File

@@ -0,0 +1,67 @@
import { Index, JoinColumn, OneToOne } from 'typeorm';
import { Column } from 'typeorm/decorator/columns/Column';
import { PrimaryGeneratedColumn } from 'typeorm/decorator/columns/PrimaryGeneratedColumn';
import { Entity } from 'typeorm/decorator/entity/Entity';
import { AssetEntity } from './asset.entity';
@Entity('exif')
export class ExifEntity {
@PrimaryGeneratedColumn()
id: string;
@Index({ unique: true })
@Column({ type: 'uuid' })
assetId: string;
@Column({ nullable: true })
make: string;
@Column({ nullable: true })
model: string;
@Column({ nullable: true })
imageName: string;
@Column({ nullable: true })
exifImageWidth: number;
@Column({ nullable: true })
exifImageHeight: number;
@Column({ nullable: true })
fileSizeInByte: number;
@Column({ nullable: true })
orientation: string;
@Column({ type: 'timestamptz', nullable: true })
dateTimeOriginal: Date;
@Column({ type: 'timestamptz', nullable: true })
modifyDate: Date;
@Column({ nullable: true })
lensModel: string;
@Column({ type: 'float8', nullable: true })
fNumber: number;
@Column({ type: 'float8', nullable: true })
focalLength: number;
@Column({ nullable: true })
iso: number;
@Column({ type: 'float', nullable: true })
exposureTime: number;
@Column({ type: 'float', nullable: true })
latitude: number;
@Column({ type: 'float', nullable: true })
longitude: number;
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
asset: ExifEntity;
}

View File

@@ -13,6 +13,7 @@ import { immichAppConfig } from './config/app.config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { ImageOptimizeModule } from './modules/image-optimize/image-optimize.module'; import { ImageOptimizeModule } from './modules/image-optimize/image-optimize.module';
import { ServerInfoModule } from './api-v1/server-info/server-info.module'; import { ServerInfoModule } from './api-v1/server-info/server-info.module';
import { BackgroundTaskModule } from './modules/background-task/background-task.module';
@Module({ @Module({
imports: [ imports: [
@@ -29,7 +30,6 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
redis: { redis: {
host: 'immich_redis', host: 'immich_redis',
port: 6379, port: 6379,
// password: configService.get('REDIS_PASSWORD'),
}, },
}), }),
inject: [ConfigService], inject: [ConfigService],
@@ -38,12 +38,14 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
ImageOptimizeModule, ImageOptimizeModule,
ServerInfoModule, ServerInfoModule,
BackgroundTaskModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void { configure(consumer: MiddlewareConsumer): void {
consumer.apply(AppLoggerMiddleware).forRoutes('*'); // consumer.apply(AppLoggerMiddleware).forRoutes('*');
} }
} }

View File

@@ -11,7 +11,7 @@ export const multerConfig = {
export const multerOption: MulterOptions = { export const multerOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => { fileFilter: (req: Request, file: any, cb: any) => {
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime)$/)) { if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif)$/)) {
cb(null, true); cb(null, true);
} else { } else {
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false); cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);

View File

@@ -0,0 +1,24 @@
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
import { BackgroundTaskProcessor } from './background-task.processor';
import { BackgroundTaskService } from './background-task.service';
@Module({
imports: [
BullModule.registerQueue({
name: 'background-task',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
],
providers: [BackgroundTaskService, BackgroundTaskProcessor],
exports: [BackgroundTaskService],
})
export class BackgroundTaskModule {}

View File

@@ -0,0 +1,59 @@
import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Job, Queue } from 'bull';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ConfigService } from '@nestjs/config';
import exifr from 'exifr';
import { readFile } from 'fs/promises';
import { Logger } from '@nestjs/common';
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
@Processor('background-task')
export class BackgroundTaskProcessor {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
private configService: ConfigService,
) {}
@Process('extract-exif')
async extractExif(job: Job) {
const { savedAsset, fileName, fileSize }: { savedAsset: AssetEntity; fileName: string; fileSize: number } =
job.data;
const fileBuffer = await readFile(savedAsset.originalPath);
const exifData = await exifr.parse(fileBuffer);
const newExif = new ExifEntity();
newExif.assetId = savedAsset.id;
newExif.make = exifData['Make'] || null;
newExif.model = exifData['Model'] || null;
newExif.imageName = fileName || null;
newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
newExif.fileSizeInByte = fileSize || null;
newExif.orientation = exifData['Orientation'] || null;
newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null;
newExif.modifyDate = exifData['ModifyDate'] || null;
newExif.lensModel = exifData['LensModel'] || null;
newExif.fNumber = exifData['FNumber'] || null;
newExif.focalLength = exifData['FocalLength'] || null;
newExif.iso = exifData['ISO'] || null;
newExif.exposureTime = exifData['ExposureTime'] || null;
newExif.latitude = exifData['latitude'] || null;
newExif.longitude = exifData['longitude'] || null;
await this.exifRepository.save(newExif);
try {
} catch (e) {
Logger.error(`Error extracting EXIF ${e.toString()}`, 'extractExif');
}
}
}

View File

@@ -0,0 +1,25 @@
import { InjectQueue } from '@nestjs/bull/dist/decorators';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import { randomUUID } from 'node:crypto';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
@Injectable()
export class BackgroundTaskService {
constructor(
@InjectQueue('background-task')
private backgroundTaskQueue: Queue,
) {}
async extractExif(savedAsset: AssetEntity, fileName: string, fileSize: number) {
const job = await this.backgroundTaskQueue.add(
'extract-exif',
{
savedAsset,
fileName,
fileSize,
},
{ jobId: randomUUID() },
);
}
}

View File

@@ -7,7 +7,6 @@ import { AssetService } from '../../api-v1/asset/asset.service';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ImageOptimizeProcessor } from './image-optimize.processor'; import { ImageOptimizeProcessor } from './image-optimize.processor';
import { AssetOptimizeService } from './image-optimize.service'; import { AssetOptimizeService } from './image-optimize.service';
import { MachineLearningProcessor } from './machine-learning.processor';
@Module({ @Module({
imports: [ imports: [
@@ -19,18 +18,10 @@ import { MachineLearningProcessor } from './machine-learning.processor';
removeOnFail: false, removeOnFail: false,
}, },
}), }),
BullModule.registerQueue({
name: 'machine-learning',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
TypeOrmModule.forFeature([AssetEntity]), TypeOrmModule.forFeature([AssetEntity]),
], ],
providers: [AssetOptimizeService, ImageOptimizeProcessor, MachineLearningProcessor], providers: [AssetOptimizeService, ImageOptimizeProcessor],
exports: [AssetOptimizeService], exports: [AssetOptimizeService],
}) })
export class ImageOptimizeModule {} export class ImageOptimizeModule {}

View File

@@ -4,16 +4,16 @@ import { Job, Queue } from 'bull';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import sharp from 'sharp'; import sharp from 'sharp';
import fs, { existsSync, mkdirSync } from 'fs'; import { existsSync, mkdirSync, readFile } from 'fs';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import { Logger } from '@nestjs/common';
@Processor('optimize') @Processor('optimize')
export class ImageOptimizeProcessor { export class ImageOptimizeProcessor {
constructor( constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>, @InjectRepository(AssetEntity)
@InjectQueue('machine-learning') private machineLearningQueue: Queue, private assetRepository: Repository<AssetEntity>,
private configService: ConfigService, private configService: ConfigService,
) {} ) {}
@@ -32,29 +32,42 @@ export class ImageOptimizeProcessor {
mkdirSync(resizeDir, { recursive: true }); mkdirSync(resizeDir, { recursive: true });
} }
fs.readFile(savedAsset.originalPath, (err, data) => { readFile(savedAsset.originalPath, async (err, data) => {
if (err) { if (err) {
console.error('Error Reading File'); console.error('Error Reading File');
} }
sharp(data) if (savedAsset.mimeType == 'image/heic' || savedAsset.mimeType == 'image/heif') {
.resize(512, 512, { fit: 'outside' }) let desitnation = '';
.toFile(resizePath, async (err, info) => { if (savedAsset.mimeType == 'image/heic') {
if (err) { desitnation = resizePath.replace('.HEIC', '.jpeg');
console.error('Error resizing file ', err); } else {
} desitnation = resizePath.replace('.HEIF', '.jpeg');
}
await this.assetRepository.update(savedAsset, { resizePath: resizePath }); sharp(data)
.toFormat('jpeg')
.resize(512, 512, { fit: 'outside' })
.toFile(desitnation, async (err, info) => {
if (err) {
console.error('Error resizing file ', err);
return;
}
// Send file to object detection after resizing await this.assetRepository.update(savedAsset, { resizePath: desitnation });
// const detectionJob = await this.machineLearningQueue.add( });
// 'object-detection', } else {
// { sharp(data)
// resizePath, .resize(512, 512, { fit: 'outside' })
// }, .toFile(resizePath, async (err, info) => {
// { jobId: randomUUID() }, if (err) {
// ); console.error('Error resizing file ', err);
}); return;
}
await this.assetRepository.update(savedAsset, { resizePath: resizePath });
});
}
}); });
return 'ok'; return 'ok';
@@ -66,7 +79,6 @@ export class ImageOptimizeProcessor {
const basePath = this.configService.get('UPLOAD_LOCATION'); const basePath = this.configService.get('UPLOAD_LOCATION');
// const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/'); // const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/');
console.log(filename);
// Create folder for thumb image if not exist // Create folder for thumb image if not exist
const resizeDir = `${basePath}/${savedAsset.userId}/thumb/${savedAsset.deviceId}`; const resizeDir = `${basePath}/${savedAsset.userId}/thumb/${savedAsset.deviceId}`;

View File

@@ -22,7 +22,7 @@ export class AssetOptimizeService {
}; };
} }
public async getVideoThumbnail(savedAsset: AssetEntity, filename: String) { public async getVideoThumbnail(savedAsset: AssetEntity, filename: string) {
const job = await this.optimizeQueue.add( const job = await this.optimizeQueue.add(
'get-video-thumbnail', 'get-video-thumbnail',
{ {

View File

@@ -1,38 +0,0 @@
import { Process, Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Job } from 'bull';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import fs from 'fs';
import { ConfigService } from '@nestjs/config';
import * as tfnode from '@tensorflow/tfjs-node';
import * as cocoSsd from '@tensorflow-models/coco-ssd';
@Processor('machine-learning')
export class MachineLearningProcessor {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
private configService: ConfigService,
) {}
@Process('object-detection')
async handleOptimization(job: Job) {
try {
const { resizePath }: { resizePath: string } = job.data;
const image = fs.readFileSync(resizePath);
const decodedImage = tfnode.node.decodeImage(image, 3) as tfnode.Tensor3D;
const model = await cocoSsd.load();
const predictions = await model.detect(decodedImage);
console.log('\n\nstart predictions ------------------ ');
for (var result of predictions) {
console.log(`Found ${result.class} with score ${result.score}`);
}
console.log('end predictions ------------------\n\n');
return 'ok';
} catch (e) {
console.log('Error object detection ', e);
}
}
}

File diff suppressed because it is too large Load Diff