Compare commits
20 Commits
v1.22.0_32
...
v1.24.0_34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25338ce02f | ||
|
|
4805d86a7c | ||
|
|
33b1410d82 | ||
|
|
f35ebec7c6 | ||
|
|
3aa6ee0320 | ||
|
|
cdb0aa00d8 | ||
|
|
9de7b8d3a7 | ||
|
|
4a28a46612 | ||
|
|
16561d15ff | ||
|
|
9642ad2820 | ||
|
|
e2169a26c2 | ||
|
|
f697922f32 | ||
|
|
1390d01763 | ||
|
|
86f780871c | ||
|
|
c1b22125fd | ||
|
|
30f069a5db | ||
|
|
2bf6cd9241 | ||
|
|
87d2a954a3 | ||
|
|
a388c5a642 | ||
|
|
4b34f017ca |
19
.github/workflows/github-repo-stats.yml
vendored
Normal file
19
.github/workflows/github-repo-stats.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: github-repo-stats
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run this once per day, towards the end of the day for keeping the most
|
||||
# recent data point most meaningful (hours are interpreted in UTC).
|
||||
- cron: "0 23 * * *"
|
||||
workflow_dispatch: # Allow for running this manually.
|
||||
|
||||
jobs:
|
||||
j1:
|
||||
name: github-repo-stats
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: run-ghrs
|
||||
# Use latest release.
|
||||
uses: jgehrcke/github-repo-stats@RELEASE
|
||||
with:
|
||||
ghtoken: ${{ secrets.GHRS_GITHUB_API_TOKEN }}
|
||||
255
README.md
255
README.md
@@ -1,3 +1,9 @@
|
||||
<h1 align="center"> Immich </h1>
|
||||
<p align="center"> <b>High performance self-hosted photo and video backup solution.</b> </p>
|
||||
<p align="center">
|
||||
<img src="design/feature-panel.png" title="Immich Logo">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
|
||||
<a href="https://github.com/alextran1502/immich"><img src="https://img.shields.io/github/stars/alextran1502/immich.svg?style=for-the-badge&logo=github&color=3F51B5&label=Stars&logoColor=000000&labelColor=ececec" alt="Star on Github"></a>
|
||||
@@ -14,75 +20,66 @@
|
||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/>
|
||||
</a>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<img src="design/feature-panel.png" title="Immich Logo">
|
||||
</p>
|
||||
<br/>
|
||||
</p>
|
||||
|
||||
# Immich
|
||||
|
||||
**High performance self-hosted photo and video backup solution.**
|
||||
|
||||

|
||||
|
||||
Loading ~4000 images/videos
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Mobile
|
||||
<p align="left">
|
||||
<img src="design/login-screen.png" width="150" title="Login With Custom URL">
|
||||
<img src="design/backup-screen.png" width="150" title="Backup Setting Info">
|
||||
<img src="design/selective-backup-screen.png" width="150" title="Backup Setting Info">
|
||||
<img src="design/home-screen.jpeg" width="150" title="Home Screen">
|
||||
<img src="design/search-screen.jpeg" width="150" title="Curated Search Info">
|
||||
<img src="design/shared-albums.png" width="150" title="Shared Albums">
|
||||
<img src="design/nsc6.png" width="150" title="EXIF Info">
|
||||
</p>
|
||||
|
||||
### Web
|
||||
<p align="left">
|
||||
<img src="design/web-home.jpeg" width="49%" title="Home Dashboard">
|
||||
<img src="design/web-detail.jpeg" width="49%" title="Detail">
|
||||
</p>
|
||||
|
||||
# Note
|
||||
|
||||
**!! NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS !!**
|
||||
|
||||
This project is under heavy development, there will be continuous functions, features and api changes.
|
||||
## Content
|
||||
- [Features](#features)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Installation](#installation)
|
||||
- [Mobile App](#-mobile-app)
|
||||
- [Development](#development)
|
||||
- [Support](#support)
|
||||
- [Known Issues](#known-issues)
|
||||
|
||||
# Features
|
||||
|
||||
> ⚠️ WARNING: **NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS**. This project is under heavy development, there will be continuous functions, features and api changes.
|
||||
|
||||
| | Mobile | Web |
|
||||
| - | - | - |
|
||||
| Upload and view videos and photos | Yes | Yes
|
||||
| Auto backup when the app is opened | Yes | N/A
|
||||
| Selective album(s) for backup | Yes | N/A
|
||||
| Download photos and videos to local device | Yes | Yes
|
||||
| Multi-user support | Yes | Yes
|
||||
| Album | Yes | Yes
|
||||
| Shared Albums | Yes | Yes
|
||||
| Quick navigation with draggable scrollbar | Yes | Yes
|
||||
| Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes
|
||||
| Metadata view (EXIF, map) | Yes | Yes
|
||||
| Search by metadata, objects and image tags | Yes | No
|
||||
| Administrative functions (user management) | N/A | Yes
|
||||
| - | - | - |
|
||||
| ☁️ Upload and view videos and photos | Yes | Yes
|
||||
| 🔄 Auto backup when the app is opened | Yes | N/A
|
||||
| ☑️ Selective album(s) for backup | Yes | N/A
|
||||
| ⬇️ Download photos and videos to local device | Yes | Yes
|
||||
| 👪 Multi-user support | Yes | Yes
|
||||
| 🖼️ Album | Yes | Yes
|
||||
| 🤝 Shared Albums | Yes | Yes
|
||||
| 🚀 Quick navigation with draggable scrollbar | Yes | Yes
|
||||
| 🗃️ Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes
|
||||
| 🧭 Metadata view (EXIF, map) | Yes | Yes
|
||||
| 🔎 Search by metadata, objects and image tags | Yes | No
|
||||
| ⚙️ Administrative functions (user management) | N/A | Yes
|
||||
|
||||
|
||||
# System Requirement
|
||||
<br/>
|
||||
|
||||
**OS**: Preferred unix-based operating system (Ubuntu, Debian, MacOS...etc).
|
||||
# Screenshots
|
||||
|
||||
**RAM**: At least 2GB, preffered 4GB.
|
||||
### Mobile
|
||||
| | | | | |
|
||||
| - | - | - | - | - |
|
||||
| <img src="design/login-screen.png" width="150" title="Login With Custom URL"> <p align="center"> Login with custom URL </p> | <img src="design/backup-screen.png" width="150" title="Backup Setting Info"> <p align="center"> Backup Settings </p> | <img src="design/selective-backup-screen.png" width="150" title="Backup Setting Info"> <p align="center"> Backup selection </p> | <img src="design/home-screen.jpeg" width="150" title="Home Screen"> <p align="center"> Home Screen </p> | <img src="design/search-screen.jpeg" width="150" title="Curated Search Info"> <p align="center"> Curated search </p> |
|
||||
| <img src="design/shared-albums.png" width="150" title="Shared Albums"> <p align="center"> Shared albums </p> | <img src="design/nsc6.png" width="150" title="EXIF Info"> <p align="center"> EXIF info </p> | <img src="https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif" width="150" title="Loading ~4000 images/videos"> <p align="center"> Loading ~4000 images/videos </p> |
|
||||
|
||||
**Core**: At least 2 cores, preffered 4 cores.
|
||||
### Web
|
||||
| Home Dashboard | Image view |
|
||||
| - | - |
|
||||
|<img src="design/web-home.jpeg" width="100%" title="Home Dashboard"> | <img src="design/web-detail.jpeg" width="100%" title="Detail">|
|
||||
|
||||
# Technology Stack
|
||||
|
||||
<br/>
|
||||
|
||||
# Project Details
|
||||
## 💾 System Requirements
|
||||
|
||||
- **OS**: Preferred unix-based operating system (Ubuntu, Debian, MacOS...etc).
|
||||
|
||||
- **RAM**: At least 2GB, preferred 4GB.
|
||||
|
||||
- **Core**: At least 2 cores, preferred 4 cores.
|
||||
|
||||
## 🔩 Technology Stack
|
||||
|
||||
There are several services that compose Immich:
|
||||
|
||||
@@ -93,15 +90,18 @@ There are several services that compose Immich:
|
||||
5. **Nginx** - Load balancing and optimized file uploading.
|
||||
6. **TensorFlow** - Object Detection (COCO SSD) and Image Classification (ImageNet).
|
||||
|
||||
# Installing
|
||||
|
||||
## One-step installation - for evaluating only
|
||||
<br/>
|
||||
|
||||
# Installation
|
||||
|
||||
## Testing One-step installation (not recommended for production)
|
||||
|
||||
> ⚠️ *This installation method is for evaluating Immich before futher customization to meet the users' needs.*
|
||||
|
||||
*Applicable system: Ubuntu, Debian, MacOS*
|
||||
|
||||
*This installation method is for evaluating Immich before futher customization to meet the users' needs.*
|
||||
|
||||
In the shell, from the directory of your choice, run the following command:
|
||||
- In the shell, from the directory of your choice, run the following command:
|
||||
|
||||
```bash
|
||||
curl -o- https://raw.githubusercontent.com/immich-app/immich/main/install.sh | bash
|
||||
@@ -114,116 +114,70 @@ The web application will be available at `http://<machine-ip-address>:2283`, and
|
||||
The directory which is used to store the backup file is `./immich-app/immich-data`.
|
||||
|
||||
|
||||
## Customize installation - for production usage
|
||||
<br/>
|
||||
|
||||
## Custom installation (Recommended)
|
||||
|
||||
### Step 1 - Download necessary files
|
||||
|
||||
Create a directory called `immich-app` and cd into it. Then
|
||||
- Create a directory called `immich-app` and cd into it.
|
||||
|
||||
Get `docker-compose.yml`
|
||||
- Get `docker-compose.yml`
|
||||
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml
|
||||
```
|
||||
|
||||
Get `.env`
|
||||
- Get `.env`
|
||||
|
||||
```bash
|
||||
wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example
|
||||
```
|
||||
|
||||
### Step 2 - Populate .env file with customed information
|
||||
### Step 2 - Populate .env file with custom information
|
||||
|
||||
* Populate customised database information if necessary.
|
||||
<a href="https://github.com/immich-app/immich/blob/main/docker/.env.example" target="_blank"><b>See the example <code>.env</code> file</b></a>
|
||||
|
||||
* Populate custom database information if necessary.
|
||||
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
|
||||
* Populate a secret value for `JWT_SECRET`
|
||||
* [Optional] Populate Mapbox value.
|
||||
|
||||
**Example**
|
||||
|
||||
```bash
|
||||
###################################################################################
|
||||
# Database
|
||||
###################################################################################
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_DATABASE_NAME=immich
|
||||
|
||||
###################################################################################
|
||||
# Upload File Config
|
||||
###################################################################################
|
||||
UPLOAD_LOCATION=<put-the-path-of-the-upload-folder-here>
|
||||
|
||||
###################################################################################
|
||||
# JWT SECRET
|
||||
###################################################################################
|
||||
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
|
||||
|
||||
###################################################################################
|
||||
# MAPBOX
|
||||
####################################################################################
|
||||
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
|
||||
ENABLE_MAPBOX=false
|
||||
MAPBOX_KEY=
|
||||
```
|
||||
* Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128`
|
||||
* [Optional] Populate Mapbox value to use reverse geocoding.
|
||||
|
||||
### Step 3 - Start the containers
|
||||
|
||||
Run `docker-compose up` or `docker compose up` (based on your docker's version)
|
||||
- Run `docker-compose up` or `docker compose up` (based on your docker's version)
|
||||
|
||||
### Step 4 - Register admin user
|
||||
|
||||
Navigate to the web at `http://<machine-ip-address>:2283` and follow the prompts to register admin user.
|
||||
|
||||
<p align="left">
|
||||
- Navigate to the web at `http://<machine-ip-address>:2283` and follow the prompts to register admin user.
|
||||
<p align="center">
|
||||
<img src="design/admin-registration-form.png" width="300" title="Admin Registration">
|
||||
<p/>
|
||||
</p>
|
||||
|
||||
Additional accounts on the server can be created by the admin account.
|
||||
|
||||
<p align="left">
|
||||
<img src="design/admin-interface.png" width="500" title="Admin User Management">
|
||||
<p/>
|
||||
- You can add and manage users from the administration page.
|
||||
<p align="center">
|
||||
<img src="design/admin-interface.png" width="500" title="Admin User Management">
|
||||
</p>
|
||||
|
||||
### Step 5 - Access the mobile app
|
||||
|
||||
Login the mobile app with the server endpoint URL at `http://<machine-ip-address>:2283/api`
|
||||
|
||||
<p align="left">
|
||||
- Login the mobile app with the server endpoint URL at `http://<machine-ip-address>:2283/api`
|
||||
<p align="center">
|
||||
<img src="design/login-screen.jpeg" width="250" title="Example login screen">
|
||||
<p/>
|
||||
</p>
|
||||
|
||||
## Mobile app
|
||||
<br/>
|
||||
|
||||
## F-Droid
|
||||
You can get the app on F-droid by clicking the image below.
|
||||
# Mobile app
|
||||
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid"
|
||||
height="80">](https://f-droid.org/packages/app.alextran.immich)
|
||||
| F-Droid | Google Play | iOS |
|
||||
| - | - | - |
|
||||
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <img src="design/google-play-qr-code.png" width="200" title="Google Play Store"> <p/> | <p align="left"> <img src="design/ios-qr-code.png" width="200" title="Apple App Store"> <p/> |
|
||||
|
||||
> *The App version might be lagging behind the latest release due to the review process.*
|
||||
|
||||
|
||||
## Android
|
||||
|
||||
#### Get the app on Google Play Store [here](https://play.google.com/store/apps/details?id=app.alextran.immich)
|
||||
|
||||
*The App version might be lagging behind the latest release due to the review process.*
|
||||
|
||||
<p align="left">
|
||||
<img src="design/google-play-qr-code.png" width="200" title="Google Play Store">
|
||||
<p/>
|
||||
|
||||
## iOS
|
||||
|
||||
#### Get the app on Apple AppStore [here](https://apps.apple.com/us/app/immich/id1613945652):
|
||||
|
||||
*The App version might be lagging behind the latest release due to the review process.*
|
||||
|
||||
|
||||
<p align="left">
|
||||
<img src="design/ios-qr-code.png" width="200" title="Apple App Store">
|
||||
<p/>
|
||||
|
||||
<br/>
|
||||
|
||||
# Development
|
||||
|
||||
@@ -244,31 +198,28 @@ npm run api:generate # Run from server directory
|
||||
```
|
||||
You can find the generated client SDK in the [`web/src/api`](web/src/api) for Typescript SDK and [`mobile/openapi`](mobile/openapi) for Dart SDK.
|
||||
|
||||
|
||||
<br/>
|
||||
|
||||
# Support
|
||||
|
||||
If you like the app, find it helpful, and want to support me to offset the cost of publishing to AppStores, you can sponsor the project with [**one time**](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) or monthly donation from [**Github Sponsor**](https://github.com/sponsors/alextran1502)
|
||||
If you like the app, find it helpful, and want to support me to offset the cost of publishing to AppStores, you can sponsor the project with [**one time**](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) or monthly donation from [**Github Sponsor**](https://github.com/sponsors/alextran1502).
|
||||
|
||||
You can also donate using crypto currency with the following addresses:
|
||||
|
||||
<p align="left" style="display: flex; place-items: center; gap: 20px" title="Bitcoin(BTC)">
|
||||
<img src="design/bitcoin.png" width="25" title="Bitcoin">
|
||||
<code>1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX</code>
|
||||
</p>
|
||||
<p align="" style="display: flex; place-items: center; gap: 15px" title="Bitcoin(BTC)"><img src="design/bitcoin.png" width="25" title="Bitcoin"> <b>Bitcoin</b>: <code>1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX</code></p>
|
||||
|
||||
|
||||
<p align="left" style="display: flex; place-items: center; gap: 15px" title="Cardano(ADA)">
|
||||
<img src="design/cardano.png" width="30" title="Cardano">
|
||||
<code>
|
||||
addr1qyy567vqhqrr3p7vpszr5p264gw89sqcwts2z8wqy4yek87cdmy79zazyjp7tmwhkluhk3krvslkzfvg0h43tytp3f5q49nycc
|
||||
</code>
|
||||
</p>
|
||||
<p align="" style="display: flex; place-items: center; gap: 15px" title="Cardano(ADA)"> <img src="design/cardano.png" width="30" title="Cardano"> <b>Cardano</b>: <code>addr1qyy567vqhqrr3p7vpszr5p264gw89sqcwts2z8wqy4yek87cdmy79zazyjp7tmwhkluhk3krvslkzfvg0h43tytp3f5q49nycc</code> </p>
|
||||
|
||||
|
||||
This is also a meaningful way to give me motivation and encouragement to continue working on the app.
|
||||
|
||||
Cheers! 🎉
|
||||
|
||||
# Known Issue
|
||||
|
||||
<br/>
|
||||
|
||||
# Known Issues
|
||||
|
||||
## TensorFlow Build Issue
|
||||
|
||||
|
||||
@@ -80,5 +80,8 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
||||
implementation "com.google.guava:guava:$guava_version"
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.example.immich_mobile
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Android plugin for Dart `BackgroundService`
|
||||
*
|
||||
* Receives messages/method calls from the foreground Dart side to manage
|
||||
* the background service, e.g. start (enqueue), stop (cancel)
|
||||
*/
|
||||
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var context: Context? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
|
||||
}
|
||||
|
||||
private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
|
||||
context = ctx
|
||||
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
|
||||
methodChannel?.setMethodCallHandler(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
onDetachedFromEngine()
|
||||
}
|
||||
|
||||
private fun onDetachedFromEngine() {
|
||||
methodChannel?.setMethodCallHandler(null)
|
||||
methodChannel = null
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
val ctx = context!!
|
||||
when(call.method) {
|
||||
"initialize" -> { // needs to be called prior to any other method
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long).apply()
|
||||
result.success(true)
|
||||
}
|
||||
"start" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val immediate = args.get(0) as Boolean
|
||||
val keepExisting = args.get(1) as Boolean
|
||||
val requireUnmeteredNetwork = args.get(2) as Boolean
|
||||
val requireCharging = args.get(3) as Boolean
|
||||
val notificationTitle = args.get(4) as String
|
||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, notificationTitle).apply()
|
||||
BackupWorker.startWork(ctx, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
|
||||
result.success(true)
|
||||
}
|
||||
"stop" -> {
|
||||
BackupWorker.stopWork(ctx)
|
||||
result.success(true)
|
||||
}
|
||||
"isEnabled" -> {
|
||||
result.success(BackupWorker.isEnabled(ctx))
|
||||
}
|
||||
"disableBatteryOptimizations" -> {
|
||||
if(!BackupWorker.isIgnoringBatteryOptimizations(ctx)) {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val text = args.get(0) as String
|
||||
Toast.makeText(ctx, text, Toast.LENGTH_LONG).show()
|
||||
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.setData(Uri.parse("package:" + ctx.getPackageName()))
|
||||
try {
|
||||
ctx.startActivity(intent)
|
||||
} catch(e: Exception) {
|
||||
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
try {
|
||||
ctx.startActivity(intent)
|
||||
} catch (e2: Exception) {
|
||||
return result.success(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
result.success(true)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "BackgroundServicePlugin"
|
||||
@@ -0,0 +1,333 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.provider.MediaStore
|
||||
import android.provider.BaseColumns
|
||||
import android.provider.MediaStore.MediaColumns
|
||||
import android.provider.MediaStore.Images.Media
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.concurrent.futures.ResolvableFuture
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.view.FlutterCallbackInformation
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Worker executed by Android WorkManager to perform backup in background
|
||||
*
|
||||
* Starts the Dart runtime/engine and calls `_nativeEntry` function in
|
||||
* `background.service.dart` to run the actual backup logic.
|
||||
* Called by Android WorkManager when all constraints for the work are met,
|
||||
* i.e. a new photo/video is created on the device AND battery is not low.
|
||||
* Optionally, unmetered network (wifi) and charging can be required.
|
||||
* As this work is not triggered periodically, but on content change, the
|
||||
* worker enqueues itself again with the same settings.
|
||||
* In case the worker is stopped by the system (e.g. constraints like wifi
|
||||
* are no longer met, or the system needs memory resources for more other
|
||||
* more important work), the worker is replaced without the constraint on
|
||||
* changed contents to run again as soon as deemed possible by the system.
|
||||
*/
|
||||
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
|
||||
|
||||
private val resolvableFuture = ResolvableFuture.create<Result>()
|
||||
private var engine: FlutterEngine? = null
|
||||
private lateinit var backgroundChannel: MethodChannel
|
||||
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
|
||||
|
||||
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
|
||||
|
||||
val ctx = applicationContext
|
||||
// enqueue itself once again to continue to listen on added photos/videos
|
||||
enqueueMoreWork(ctx,
|
||||
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
||||
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false))
|
||||
|
||||
if (!flutterLoader.initialized()) {
|
||||
flutterLoader.startInitialization(ctx)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// Create a Notification channel if necessary
|
||||
createChannel()
|
||||
}
|
||||
if (isIgnoringBatteryOptimizations) {
|
||||
// normal background services can only up to 10 minutes
|
||||
// foreground services are allowed to run indefinitely
|
||||
// requires battery optimizations to be disabled (either manually by the user
|
||||
// or by the system learning that immich is important to the user)
|
||||
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
|
||||
setForegroundAsync(createForegroundInfo(title))
|
||||
}
|
||||
engine = FlutterEngine(ctx)
|
||||
|
||||
flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
|
||||
runDart()
|
||||
}
|
||||
|
||||
return resolvableFuture
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Dart runtime/engine and calls `_nativeEntry` function in
|
||||
* `background.service.dart` to run the actual backup logic.
|
||||
*/
|
||||
private fun runDart() {
|
||||
val callbackDispatcherHandle = applicationContext.getSharedPreferences(
|
||||
SHARED_PREF_NAME, Context.MODE_PRIVATE).getLong(SHARED_PREF_CALLBACK_KEY, 0L)
|
||||
val callbackInformation = FlutterCallbackInformation.lookupCallbackInformation(callbackDispatcherHandle)
|
||||
val appBundlePath = flutterLoader.findAppBundlePath()
|
||||
|
||||
engine?.let { engine ->
|
||||
backgroundChannel = MethodChannel(engine.dartExecutor, "immich/backgroundChannel")
|
||||
backgroundChannel.setMethodCallHandler(this@BackupWorker)
|
||||
engine.dartExecutor.executeDartCallback(
|
||||
DartExecutor.DartCallback(
|
||||
applicationContext.assets,
|
||||
appBundlePath,
|
||||
callbackInformation
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopped() {
|
||||
// called when the system has to stop this worker because constraints are
|
||||
// no longer met or the system needs resources for more important tasks
|
||||
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
||||
backgroundChannel.invokeMethod("systemStop", null)
|
||||
}
|
||||
// cannot await/get(block) on resolvableFuture as its already cancelled (would throw CancellationException)
|
||||
// instead, wait for 5 seconds until forcefully stopping backup work
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
stopEngine(null)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
|
||||
private fun stopEngine(result: Result?) {
|
||||
if (result != null) {
|
||||
resolvableFuture.set(result)
|
||||
} else if (engine != null && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) {
|
||||
// stopped by system and this is the first time (content change constraints active)
|
||||
// replace the task without the content constraints to finish the backup as soon as possible
|
||||
enqueueMoreWork(applicationContext,
|
||||
immediate = true,
|
||||
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
||||
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
||||
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
||||
}
|
||||
engine?.destroy()
|
||||
engine = null
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialized" ->
|
||||
backgroundChannel.invokeMethod(
|
||||
"onAssetsChanged",
|
||||
null,
|
||||
object : MethodChannel.Result {
|
||||
override fun notImplemented() {
|
||||
stopEngine(Result.failure())
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
stopEngine(Result.failure())
|
||||
}
|
||||
|
||||
override fun success(receivedResult: Any?) {
|
||||
val success = receivedResult as Boolean
|
||||
stopEngine(if(success) Result.success() else Result.retry())
|
||||
if (!success && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) {
|
||||
// there was an error (e.g. server not available)
|
||||
// replace the task without the content constraints to finish the backup as soon as possible
|
||||
enqueueMoreWork(applicationContext,
|
||||
immediate = true,
|
||||
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
|
||||
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
|
||||
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
"updateNotification" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val title = args.get(0) as String
|
||||
val content = args.get(1) as String
|
||||
if (isIgnoringBatteryOptimizations) {
|
||||
setForegroundAsync(createForegroundInfo(title, content))
|
||||
}
|
||||
}
|
||||
"showError" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
val title = args.get(0) as String
|
||||
val content = args.get(1) as String
|
||||
showError(title, content)
|
||||
}
|
||||
else -> r.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(title: String, content: String) {
|
||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
|
||||
.setContentTitle(title)
|
||||
.setTicker(title)
|
||||
.setContentText(content)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
val notificationId = SystemClock.uptimeMillis() as Int
|
||||
notificationManager.notify(notificationId, notification)
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
|
||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setTicker(title)
|
||||
.setContentText(content)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
return ForegroundInfo(1, notification)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun createChannel() {
|
||||
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
|
||||
notificationManager.createNotificationChannel(foreground)
|
||||
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH)
|
||||
notificationManager.createNotificationChannel(error)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SHARED_PREF_NAME = "immichBackgroundService"
|
||||
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
|
||||
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
|
||||
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
|
||||
|
||||
private const val TASK_NAME = "immich/photoListener"
|
||||
private const val DATA_KEY_UNMETERED = "unmetered"
|
||||
private const val DATA_KEY_CHARGING = "charging"
|
||||
private const val DATA_KEY_RETRIES = "retries"
|
||||
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
|
||||
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
|
||||
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
||||
|
||||
/**
|
||||
* Enqueues the `BackupWorker` to run when all constraints are met.
|
||||
*
|
||||
* @param context Android Context
|
||||
* @param immediate whether to enqueue(replace) the worker without the content change constraint
|
||||
* @param keepExisting if true, use `ExistingWorkPolicy.KEEP`, else `ExistingWorkPolicy.APPEND_OR_REPLACE`
|
||||
* @param requireUnmeteredNetwork if true, task only runs if connected to wifi
|
||||
* @param requireCharging if true, task only runs if device is charging
|
||||
* @param retries retry count (should be 0 unless an error occured and this is a retry)
|
||||
*/
|
||||
fun startWork(context: Context,
|
||||
immediate: Boolean = false,
|
||||
keepExisting: Boolean = false,
|
||||
requireUnmeteredNetwork: Boolean = false,
|
||||
requireCharging: Boolean = false) {
|
||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, true).apply()
|
||||
enqueueMoreWork(context, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
|
||||
}
|
||||
|
||||
private fun enqueueMoreWork(context: Context,
|
||||
immediate: Boolean = false,
|
||||
keepExisting: Boolean = false,
|
||||
requireUnmeteredNetwork: Boolean = false,
|
||||
requireCharging: Boolean = false,
|
||||
retries: Int = 0) {
|
||||
if (!isEnabled(context)) {
|
||||
return
|
||||
}
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(if (requireUnmeteredNetwork) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.setRequiresCharging(requireCharging);
|
||||
if (!immediate) {
|
||||
constraints
|
||||
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
|
||||
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
|
||||
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
|
||||
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
|
||||
}
|
||||
|
||||
val inputData = Data.Builder()
|
||||
.putBoolean(DATA_KEY_CHARGING, requireCharging)
|
||||
.putBoolean(DATA_KEY_UNMETERED, requireUnmeteredNetwork)
|
||||
.putInt(DATA_KEY_RETRIES, retries)
|
||||
.build()
|
||||
|
||||
val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java)
|
||||
.setConstraints(constraints.build())
|
||||
.setInputData(inputData)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
OneTimeWorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE)
|
||||
val op = WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME, policy, photoCheck)
|
||||
val result = op.getResult().get()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the currently running worker (if any) and removes it from the work queue
|
||||
*/
|
||||
fun stopWork(context: Context) {
|
||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
|
||||
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the app is ignoring battery optimizations
|
||||
*/
|
||||
fun isIgnoringBatteryOptimizations(ctx: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val pwrm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val name = ctx.packageName
|
||||
return pwrm.isIgnoringBatteryOptimizations(name)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the user has enabled the background backup service
|
||||
*/
|
||||
fun isEnabled(ctx: Context): Boolean {
|
||||
return ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
|
||||
}
|
||||
|
||||
private val flutterLoader = FlutterLoader()
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "BackupWorker"
|
||||
@@ -1,6 +1,13 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.getPlugins().add(BackgroundServicePlugin())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.6.10'
|
||||
ext.work_version = '2.7.1'
|
||||
ext.concurrent_version = '1.1.0'
|
||||
ext.guava_version = '31.0.1-android'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
|
||||
@@ -30,8 +30,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 32,
|
||||
"android.injected.version.name" => "1.22.0",
|
||||
"android.injected.version.code" => 34,
|
||||
"android.injected.version.name" => "1.24.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
* Added setting screen
|
||||
* Implemented dark mode
|
||||
@@ -0,0 +1,3 @@
|
||||
* Feature - [Android] Background backup.
|
||||
* Fixed - [iOS] Dark mode not auto switch.
|
||||
* Fixed - WebSocket not getting correct data on mobile.
|
||||
@@ -48,7 +48,7 @@
|
||||
"control_bottom_app_bar_delete": "Löschen",
|
||||
"create_shared_album_page_share": "Teilen",
|
||||
"create_shared_album_page_create": "Erstellen",
|
||||
"create_shared_album_page_share_add_assets": "ELEMENTE HINZUFÜGEN",
|
||||
"create_shared_album_page_share_add_assets": "FOTOS HINZUFÜGEN",
|
||||
"create_shared_album_page_share_select_photos": "Fotos auswählen",
|
||||
"daily_title_text_date": "E, dd MMM",
|
||||
"daily_title_text_date_year": "E, dd MMM, yyyy",
|
||||
@@ -76,6 +76,7 @@
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"profile_drawer_client_server_up_to_date": "App und Server sind aktuell",
|
||||
"profile_drawer_sign_out": "Abmelden",
|
||||
"profile_drawer_settings": "Einstellungen",
|
||||
"search_bar_hint": "Durchsuche deine Fotos",
|
||||
"search_page_no_objects": "Keine Objektinformationen verfügbar",
|
||||
"search_page_no_places": "Keine Informationen über Orte verfügbar",
|
||||
@@ -112,5 +113,14 @@
|
||||
"library_page_new_album": "Neues Album",
|
||||
"create_album_page_untitled": "Unbenannt",
|
||||
"share_dialog_preparing": "Vorbereiten...",
|
||||
"control_bottom_app_bar_share": "Teilen"
|
||||
"control_bottom_app_bar_share": "Teilen",
|
||||
"setting_pages_app_bar_settings": "Einstellungen",
|
||||
"theme_setting_theme_title": "Theme",
|
||||
"theme_setting_theme_subtitle": "Wählen Sie die Themeneinstellung der App",
|
||||
"theme_setting_system_theme_switch": "Automatisch (Systemeinstellung folgen)",
|
||||
"theme_setting_dark_mode_switch": "Dunkler Modus",
|
||||
"theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters",
|
||||
"theme_setting_image_viewer_quality_subtitle": "Einstellen der Qualität des Detailbildbetrachters",
|
||||
"theme_setting_three_stage_loading_title": "Dreistufiges Laden aktivieren",
|
||||
"theme_setting_three_stage_loading_subtitle": "Das dreistufige Ladeverfahren liefert die beste Bildqualität, ist dafür aber langsamer beim Laden."
|
||||
}
|
||||
|
||||
@@ -16,10 +16,23 @@
|
||||
"backup_album_selection_page_selection_info": "Selection Info",
|
||||
"backup_album_selection_page_total_assets": "Total unique assets",
|
||||
"backup_all": "All",
|
||||
"backup_background_service_default_notification": "Checking for new assets…",
|
||||
"backup_background_service_disable_battery_optimizations": "Please disable battery optimization for Immich to enable background backup",
|
||||
"backup_background_service_upload_failure_notification": "Failed to upload {}",
|
||||
"backup_background_service_in_progress_notification": "Backing up your assets…",
|
||||
"backup_background_service_current_upload_notification": "Uploading {}",
|
||||
"backup_controller_page_albums": "Backup Albums",
|
||||
"backup_controller_page_backup": "Backup",
|
||||
"backup_controller_page_backup_selected": "Selected: ",
|
||||
"backup_controller_page_backup_sub": "Backed up photos and videos",
|
||||
"backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app",
|
||||
"backup_controller_page_background_wifi": "Only on WiFi",
|
||||
"backup_controller_page_background_charging": "Only while charging",
|
||||
"backup_controller_page_background_is_on": "Automatic background backup is on",
|
||||
"backup_controller_page_background_is_off": "Automatic background backup is off",
|
||||
"backup_controller_page_background_turn_on": "Turn on background service",
|
||||
"backup_controller_page_background_turn_off": "Turn off background service",
|
||||
"backup_controller_page_background_configure_error": "Failed to configure the background service",
|
||||
"backup_controller_page_cancel": "Cancel",
|
||||
"backup_controller_page_created": "Created on: {}",
|
||||
"backup_controller_page_desc_backup": "Turn on backup to automatically upload new assets to the server.",
|
||||
@@ -48,7 +61,7 @@
|
||||
"control_bottom_app_bar_delete": "Delete",
|
||||
"create_shared_album_page_share": "Share",
|
||||
"create_shared_album_page_create": "Create",
|
||||
"create_shared_album_page_share_add_assets": "ADD ASSETS",
|
||||
"create_shared_album_page_share_add_assets": "ADD PHOTOS",
|
||||
"create_shared_album_page_share_select_photos": "Select Photos",
|
||||
"daily_title_text_date": "E, MMM dd",
|
||||
"daily_title_text_date_year": "E, MMM dd, yyyy",
|
||||
@@ -75,7 +88,8 @@
|
||||
"login_form_save_login": "Stay logged in",
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
|
||||
"profile_drawer_sign_out": "Sign Out",
|
||||
"profile_drawer_sign_out": "Sign out",
|
||||
"profile_drawer_settings": "Settings",
|
||||
"search_bar_hint": "Search your photos",
|
||||
"search_page_no_objects": "No Objects Info Available",
|
||||
"search_page_no_places": "No Places Info Available",
|
||||
@@ -112,5 +126,14 @@
|
||||
"library_page_new_album": "New album",
|
||||
"create_album_page_untitled": "Untitled",
|
||||
"share_dialog_preparing": "Preparing...",
|
||||
"control_bottom_app_bar_share": "Share"
|
||||
"control_bottom_app_bar_share": "Share",
|
||||
"setting_pages_app_bar_settings": "Settings",
|
||||
"theme_setting_theme_title": "Theme",
|
||||
"theme_setting_theme_subtitle": "Choose the app's theme setting",
|
||||
"theme_setting_system_theme_switch": "Automatic (Follow system setting)",
|
||||
"theme_setting_dark_mode_switch": "Dark mode",
|
||||
"theme_setting_image_viewer_quality_title": "Image viewer quality",
|
||||
"theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer",
|
||||
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
|
||||
"theme_setting_three_stage_loading_subtitle": "The three-stage loading delivers the best quality image in exchange for a slower loading speed"
|
||||
}
|
||||
|
||||
@@ -66,8 +66,7 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Light</string>
|
||||
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true />
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.22.0"
|
||||
version_number: "1.24.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -16,3 +16,6 @@ const String backupInfoKey = "immichBackupAlbumInfoKey"; // Key 1
|
||||
// Github Release Info
|
||||
const String hiveGithubReleaseInfoBox = "immichGithubReleaseInfoBox"; // Box
|
||||
const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1
|
||||
|
||||
// User Setting Info
|
||||
const String userSettingInfoBox = "immichUserSettingInfoBox";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const immichBackgroundColor = Color(0xFFf6f8fe);
|
||||
Color immichBackgroundColor = const Color(0xFFf6f8fe);
|
||||
Color immichDarkBackgroundColor = const Color.fromARGB(255, 0, 0, 0);
|
||||
Color immichDarkThemePrimaryColor = const Color.fromARGB(255, 173, 203, 250);
|
||||
|
||||
17
mobile/lib/constants/locales.dart
Normal file
17
mobile/lib/constants/locales.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'dart:ui';
|
||||
|
||||
const List<Locale> locales = [
|
||||
// Default locale
|
||||
Locale('en', 'US'),
|
||||
// Additional locales
|
||||
Locale('da', 'DK'),
|
||||
Locale('de', 'DE'),
|
||||
Locale('es', 'ES'),
|
||||
Locale('fi', 'FI'),
|
||||
Locale('fr', 'FR'),
|
||||
Locale('it', 'IT'),
|
||||
Locale('ja', 'JP'),
|
||||
Locale('pl', 'PL')
|
||||
];
|
||||
|
||||
const String translationsPath = 'assets/i18n';
|
||||
@@ -8,6 +8,8 @@ import 'package:flutter_displaymode/flutter_displaymode.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||
@@ -21,6 +23,7 @@ import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
|
||||
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
||||
import 'constants/hive_box.dart';
|
||||
|
||||
void main() async {
|
||||
@@ -33,6 +36,7 @@ void main() async {
|
||||
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
||||
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
await Hive.openBox(hiveGithubReleaseInfoBox);
|
||||
await Hive.openBox(userSettingInfoBox);
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
@@ -42,17 +46,6 @@ void main() async {
|
||||
|
||||
await EasyLocalization.ensureInitialized();
|
||||
|
||||
var locales = const [
|
||||
// Default locale
|
||||
Locale('en', 'US'),
|
||||
// Additional locales
|
||||
Locale('da', 'DK'),
|
||||
Locale('de', 'DE'),
|
||||
Locale('es', 'ES'),
|
||||
Locale('fr', 'FR'),
|
||||
Locale('it', 'IT'),
|
||||
];
|
||||
|
||||
if (kReleaseMode && Platform.isAndroid) {
|
||||
try {
|
||||
await FlutterDisplayMode.setHighRefreshRate();
|
||||
@@ -64,7 +57,7 @@ void main() async {
|
||||
runApp(
|
||||
EasyLocalization(
|
||||
supportedLocales: locales,
|
||||
path: 'assets/i18n',
|
||||
path: translationsPath,
|
||||
useFallbackTranslations: true,
|
||||
fallbackLocale: locales.first,
|
||||
child: const ProviderScope(child: ImmichApp()),
|
||||
@@ -91,6 +84,7 @@ class ImmichAppState extends ConsumerState<ImmichApp>
|
||||
var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated;
|
||||
|
||||
if (isAuthenticated) {
|
||||
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
ref.watch(assetProvider.notifier).getAllAsset();
|
||||
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
||||
@@ -129,8 +123,11 @@ class ImmichAppState extends ConsumerState<ImmichApp>
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
|
||||
initApp().then((_) => debugPrint("App Init Completed"));
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// needs to be delayed so that EasyLocalization is working
|
||||
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -154,23 +151,9 @@ class ImmichAppState extends ConsumerState<ImmichApp>
|
||||
MaterialApp.router(
|
||||
title: 'Immich',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
primarySwatch: Colors.indigo,
|
||||
fontFamily: 'WorkSans',
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
|
||||
),
|
||||
scaffoldBackgroundColor: immichBackgroundColor,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: immichBackgroundColor,
|
||||
foregroundColor: Colors.indigo,
|
||||
elevation: 1,
|
||||
centerTitle: true,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
||||
),
|
||||
),
|
||||
themeMode: ref.watch(immichThemeProvider),
|
||||
darkTheme: immichDarkTheme,
|
||||
theme: immichLightTheme,
|
||||
routeInformationParser: router.defaultRouteParser(),
|
||||
routerDelegate: router.delegate(
|
||||
navigatorObservers: () => [TabNavigationObserver(ref: ref)],
|
||||
|
||||
@@ -14,6 +14,8 @@ class AlbumActionOutlinedButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: OutlinedButton.icon(
|
||||
@@ -22,19 +24,23 @@ class AlbumActionOutlinedButton extends StatelessWidget {
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
side: const BorderSide(
|
||||
side: BorderSide(
|
||||
width: 1,
|
||||
color: Color.fromARGB(255, 215, 215, 215),
|
||||
color: isDarkTheme
|
||||
? const Color.fromARGB(255, 63, 63, 63)
|
||||
: const Color.fromARGB(255, 206, 206, 206),
|
||||
),
|
||||
),
|
||||
icon: Icon(iconData, size: 15),
|
||||
icon: Icon(
|
||||
iconData,
|
||||
size: 15,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
label: Text(
|
||||
labelText,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
|
||||
@@ -52,7 +52,6 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
album.albumName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -65,14 +64,14 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
? 'album_thumbnail_card_item'
|
||||
: 'album_thumbnail_card_items',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr(args: ['${album.assetCount}']),
|
||||
if (album.shared)
|
||||
const Text(
|
||||
'album_thumbnail_card_shared',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr()
|
||||
],
|
||||
|
||||
@@ -19,6 +19,8 @@ class AlbumTitleTextField extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return TextField(
|
||||
onChanged: (v) {
|
||||
if (v.isEmpty) {
|
||||
@@ -51,7 +53,10 @@ class AlbumTitleTextField extends ConsumerWidget {
|
||||
albumTitleController.clear();
|
||||
isAlbumTitleEmpty.value = true;
|
||||
},
|
||||
icon: const Icon(Icons.cancel_rounded),
|
||||
icon: Icon(
|
||||
Icons.cancel_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
splashRadius: 10,
|
||||
)
|
||||
: null,
|
||||
@@ -65,7 +70,9 @@ class AlbumTitleTextField extends ConsumerWidget {
|
||||
),
|
||||
hintText: 'share_add_title'.tr(),
|
||||
focusColor: Colors.grey[300],
|
||||
fillColor: Colors.grey[200],
|
||||
fillColor: isDarkTheme
|
||||
? const Color.fromARGB(255, 32, 33, 35)
|
||||
: Colors.grey[200],
|
||||
filled: isAlbumTitleTextFieldFocus.value,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -150,7 +150,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
|
||||
void _buildBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
backgroundColor: immichBackgroundColor,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
isScrollControlled: false,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
|
||||
@@ -18,6 +18,7 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final titleTextEditController =
|
||||
useTextEditingController(text: albumInfo.albumName);
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
void onFocusModeChange() {
|
||||
if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) {
|
||||
@@ -65,7 +66,10 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
|
||||
onPressed: () {
|
||||
titleTextEditController.clear();
|
||||
},
|
||||
icon: const Icon(Icons.cancel_rounded),
|
||||
icon: Icon(
|
||||
Icons.cancel_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
splashRadius: 10,
|
||||
)
|
||||
: null,
|
||||
@@ -78,7 +82,9 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
focusColor: Colors.grey[300],
|
||||
fillColor: Colors.grey[200],
|
||||
fillColor: isDarkTheme
|
||||
? const Color.fromARGB(255, 32, 33, 35)
|
||||
: Colors.grey[200],
|
||||
filled: titleFocusNode.hasFocus,
|
||||
hintText: 'share_add_title'.tr(),
|
||||
),
|
||||
|
||||
@@ -35,13 +35,7 @@ class SharingSliverAppBar extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
child: TextButton.icon(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all(
|
||||
Theme.of(context).primaryColor.withAlpha(20),
|
||||
),
|
||||
// foregroundColor: MaterialStateProperty.all(Colors.white),
|
||||
),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
AutoRouter.of(context)
|
||||
.push(CreateAlbumRoute(isSharedAlbum: true));
|
||||
@@ -52,8 +46,12 @@ class SharingSliverAppBar extends StatelessWidget {
|
||||
),
|
||||
label: const Text(
|
||||
"sharing_silver_appbar_create_shared_album",
|
||||
style:
|
||||
TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
// color: Theme.of(context).primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
@@ -61,13 +59,7 @@ class SharingSliverAppBar extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4.0),
|
||||
child: TextButton.icon(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all(
|
||||
Theme.of(context).primaryColor.withAlpha(20),
|
||||
),
|
||||
// foregroundColor: MaterialStateProperty.all(Colors.white),
|
||||
),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: null,
|
||||
icon: const Icon(
|
||||
Icons.swap_horizontal_circle_outlined,
|
||||
@@ -75,8 +67,11 @@ class SharingSliverAppBar extends StatelessWidget {
|
||||
),
|
||||
label: const Text(
|
||||
"sharing_silver_appbar_share_partner",
|
||||
style:
|
||||
TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
maxLines: 1,
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
@@ -242,7 +241,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
titleFocusNode.unfocus();
|
||||
},
|
||||
child: DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
@@ -255,7 +254,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
minHeight: 50,
|
||||
maxHeight: 50,
|
||||
child: Container(
|
||||
color: immichBackgroundColor,
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: _buildControlButton(albumInfo),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -43,7 +43,7 @@ class AssetSelectionPage extends HookConsumerWidget {
|
||||
return Stack(
|
||||
children: [
|
||||
DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
|
||||
@@ -27,6 +27,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
final isAlbumTitleEmpty = useState(true);
|
||||
final selectedAssets =
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
_showSelectUserPage() {
|
||||
AutoRouter.of(context).push(const SelectUserForSharingRoute());
|
||||
@@ -75,9 +76,12 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 200, left: 18),
|
||||
child: const Text(
|
||||
child: Text(
|
||||
'create_shared_album_page_share_add_assets',
|
||||
style: TextStyle(fontSize: 12),
|
||||
style: Theme.of(context).textTheme.headline2?.copyWith(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
);
|
||||
@@ -96,24 +100,28 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
alignment: Alignment.centerLeft,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 22, horizontal: 16),
|
||||
side: const BorderSide(
|
||||
color: Color.fromARGB(255, 206, 206, 206),
|
||||
side: BorderSide(
|
||||
color: isDarkTheme
|
||||
? const Color.fromARGB(255, 63, 63, 63)
|
||||
: const Color.fromARGB(255, 206, 206, 206),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
onPressed: _onSelectPhotosButtonPressed,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
icon: Icon(
|
||||
Icons.add_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
label: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Text(
|
||||
'create_shared_album_page_share_select_photos',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[700],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
@@ -190,6 +198,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
@@ -197,9 +206,11 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
title: const Text(
|
||||
title: Text(
|
||||
'share_create_album',
|
||||
style: TextStyle(color: Colors.black),
|
||||
style: Theme.of(context).textTheme.headline2?.copyWith(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
actions: [
|
||||
if (isSharedAlbum)
|
||||
@@ -209,8 +220,9 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
: null,
|
||||
child: Text(
|
||||
'create_shared_album_page_share'.tr(),
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -234,9 +246,9 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
elevation: 5,
|
||||
automaticallyImplyLeading: false,
|
||||
// leading: Container(),
|
||||
pinned: true,
|
||||
floating: false,
|
||||
bottom: PreferredSize(
|
||||
|
||||
@@ -23,7 +23,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
Widget _buildAppBar() {
|
||||
return SliverAppBar(
|
||||
return const SliverAppBar(
|
||||
centerTitle: true,
|
||||
floating: true,
|
||||
pinned: false,
|
||||
@@ -35,7 +35,6 @@ class LibraryPage extends HookConsumerWidget {
|
||||
fontFamily: 'SnowburstOne',
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 22,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -72,7 +71,6 @@ class LibraryPage extends HookConsumerWidget {
|
||||
child: const Text(
|
||||
'library_page_new_album',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
|
||||
@@ -136,9 +136,9 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
title: Text(
|
||||
'share_invite',
|
||||
style: TextStyle(color: Colors.black),
|
||||
style: TextStyle(color: Theme.of(context).primaryColor),
|
||||
).tr(),
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
@@ -150,11 +150,18 @@ class SelectUserForSharingPage extends HookConsumerWidget {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
primary: Theme.of(context).primaryColor,
|
||||
),
|
||||
onPressed:
|
||||
sharedUsersList.value.isEmpty ? null : _createSharedAlbum,
|
||||
child: const Text(
|
||||
"share_create_album",
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
// color: Theme.of(context).primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
)
|
||||
],
|
||||
|
||||
@@ -61,11 +61,9 @@ class SharingPage extends HookConsumerWidget {
|
||||
sharedAlbums[index].albumName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
AutoRouter.of(context)
|
||||
@@ -87,7 +85,7 @@ class SharingPage extends HookConsumerWidget {
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10), // if you need this
|
||||
side: const BorderSide(
|
||||
color: Colors.black12,
|
||||
color: Colors.grey,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -97,30 +95,26 @@ class SharingPage extends HookConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 5.0, bottom: 5),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 5.0, bottom: 5),
|
||||
child: Icon(
|
||||
Icons.offline_share_outlined,
|
||||
size: 50,
|
||||
color: Theme.of(context).primaryColor.withAlpha(200),
|
||||
// color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'sharing_page_empty_list',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(context).textTheme.headline3,
|
||||
).tr(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'sharing_page_description',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -56,11 +56,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
}
|
||||
|
||||
void _fireStartLoadingEvent() {
|
||||
if (widget.onLoadingStart != null) widget.onLoadingStart!();
|
||||
widget.onLoadingStart();
|
||||
}
|
||||
|
||||
void _fireFinishedLoadingEvent() {
|
||||
if (widget.onLoadingCompleted != null) widget.onLoadingCompleted!();
|
||||
widget.onLoadingCompleted();
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider _authorizedImageProvider(String url) {
|
||||
@@ -141,26 +141,26 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
}
|
||||
|
||||
class RemotePhotoView extends StatefulWidget {
|
||||
const RemotePhotoView(
|
||||
{Key? key,
|
||||
required this.thumbnailUrl,
|
||||
required this.imageUrl,
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.onSwipeDown,
|
||||
required this.onSwipeUp,
|
||||
this.previewUrl,
|
||||
this.onLoadingCompleted,
|
||||
this.onLoadingStart})
|
||||
: super(key: key);
|
||||
const RemotePhotoView({
|
||||
Key? key,
|
||||
required this.thumbnailUrl,
|
||||
required this.imageUrl,
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.onSwipeDown,
|
||||
required this.onSwipeUp,
|
||||
this.previewUrl,
|
||||
required this.onLoadingCompleted,
|
||||
required this.onLoadingStart,
|
||||
}) : super(key: key);
|
||||
|
||||
final String thumbnailUrl;
|
||||
final String imageUrl;
|
||||
final String authToken;
|
||||
final String? previewUrl;
|
||||
final Function? onLoadingCompleted;
|
||||
final Function? onLoadingStart;
|
||||
final Function onLoadingCompleted;
|
||||
final Function onLoadingStart;
|
||||
|
||||
final void Function() onSwipeDown;
|
||||
final void Function() onSwipeUp;
|
||||
|
||||
@@ -11,6 +11,8 @@ import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
@@ -18,8 +20,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
late List<AssetResponseDto> assetList;
|
||||
final AssetResponseDto asset;
|
||||
|
||||
static const _threeStageLoading = false;
|
||||
|
||||
GalleryViewerPage({
|
||||
Key? key,
|
||||
required this.assetList,
|
||||
@@ -27,21 +27,35 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
}) : super(key: key);
|
||||
|
||||
AssetResponseDto? assetDetail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final Box<dynamic> box = Hive.box(userInfoBox);
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
final threeStageLoading = useState(false);
|
||||
final loading = useState(false);
|
||||
final isZoomed = useState<bool>(false);
|
||||
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
|
||||
|
||||
int indexOfAsset = assetList.indexOf(asset);
|
||||
final loading = useState(false);
|
||||
|
||||
@override
|
||||
void initState(int index) {
|
||||
indexOfAsset = index;
|
||||
}
|
||||
|
||||
PageController controller =
|
||||
PageController(initialPage: assetList.indexOf(asset));
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
threeStageLoading.value = appSettingService
|
||||
.getSetting<bool>(AppSettingsEnum.threeStageLoading);
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@override
|
||||
initState(int index) {
|
||||
indexOfAsset = index;
|
||||
}
|
||||
|
||||
getAssetExif() async {
|
||||
assetDetail = await ref
|
||||
.watch(assetServiceProvider)
|
||||
@@ -60,9 +74,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final isZoomed = useState<bool>(false);
|
||||
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
|
||||
|
||||
//make isZoomed listener call instead
|
||||
void isZoomedMethod() {
|
||||
if (isZoomedListener.value) {
|
||||
@@ -84,7 +95,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
ref
|
||||
.watch(imageViewerStateProvider.notifier)
|
||||
.downloadAsset(assetList[indexOfAsset], context);
|
||||
}, onSharePressed: () {
|
||||
},
|
||||
onSharePressed: () {
|
||||
ref
|
||||
.watch(imageViewerStateProvider.notifier)
|
||||
.shareAsset(assetList[indexOfAsset], context);
|
||||
@@ -101,17 +113,19 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
initState(index);
|
||||
|
||||
getAssetExif();
|
||||
|
||||
if (assetList[index].type == AssetTypeEnum.IMAGE) {
|
||||
return ImageViewerPage(
|
||||
authToken: 'Bearer ${box.get(accessTokenKey)}',
|
||||
isZoomedFunction: isZoomedMethod,
|
||||
isZoomedListener: isZoomedListener,
|
||||
onLoadingCompleted: () => loading.value = false,
|
||||
onLoadingStart: () => loading.value = _threeStageLoading,
|
||||
onLoadingCompleted: () => {},
|
||||
onLoadingStart: () => {},
|
||||
asset: assetList[index],
|
||||
heroTag: assetList[index].id,
|
||||
threeStageLoading: _threeStageLoading
|
||||
threeStageLoading: threeStageLoading.value,
|
||||
);
|
||||
} else {
|
||||
return SwipeDetector(
|
||||
|
||||
@@ -35,6 +35,7 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
}) : super(key: key);
|
||||
|
||||
AssetResponseDto? assetDetail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final downloadAssetStatus =
|
||||
@@ -71,18 +72,19 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: RemotePhotoView(
|
||||
thumbnailUrl: getThumbnailUrl(asset),
|
||||
imageUrl: getImageUrl(asset),
|
||||
previewUrl: threeStageLoading
|
||||
? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
|
||||
: null,
|
||||
authToken: authToken,
|
||||
isZoomedFunction: isZoomedFunction,
|
||||
isZoomedListener: isZoomedListener,
|
||||
onSwipeDown: () => AutoRouter.of(context).pop(),
|
||||
onSwipeUp: () => showInfo(),
|
||||
onLoadingCompleted: onLoadingCompleted,
|
||||
onLoadingStart: onLoadingStart),
|
||||
thumbnailUrl: getThumbnailUrl(asset),
|
||||
imageUrl: getImageUrl(asset),
|
||||
previewUrl: threeStageLoading
|
||||
? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
|
||||
: null,
|
||||
authToken: authToken,
|
||||
isZoomedFunction: isZoomedFunction,
|
||||
isZoomedListener: isZoomedListener,
|
||||
onSwipeDown: () => AutoRouter.of(context).pop(),
|
||||
onSwipeUp: () => showInfo(),
|
||||
onLoadingCompleted: onLoadingCompleted,
|
||||
onLoadingStart: onLoadingStart,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui' show IsolateNameServer, PluginUtilities;
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.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/modules/backup/background_service/localization.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
final backgroundServiceProvider = Provider(
|
||||
(ref) => BackgroundService(),
|
||||
);
|
||||
|
||||
/// Background backup service
|
||||
class BackgroundService {
|
||||
static const String _portNameLock = "immichLock";
|
||||
BackgroundService();
|
||||
static const MethodChannel _foregroundChannel =
|
||||
MethodChannel('immich/foregroundChannel');
|
||||
static const MethodChannel _backgroundChannel =
|
||||
MethodChannel('immich/backgroundChannel');
|
||||
bool _isForegroundInitialized = false;
|
||||
bool _isBackgroundInitialized = false;
|
||||
CancellationToken? _cancellationToken;
|
||||
bool _canceledBySystem = false;
|
||||
int _wantsLockTime = 0;
|
||||
bool _hasLock = false;
|
||||
SendPort? _waitingIsolate;
|
||||
ReceivePort? _rp;
|
||||
|
||||
bool get isForegroundInitialized {
|
||||
return _isForegroundInitialized;
|
||||
}
|
||||
|
||||
bool get isBackgroundInitialized {
|
||||
return _isBackgroundInitialized;
|
||||
}
|
||||
|
||||
Future<bool> _initialize() async {
|
||||
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
|
||||
var result = await _foregroundChannel
|
||||
.invokeMethod('initialize', [callback.toRawHandle()]);
|
||||
_isForegroundInitialized = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Ensures that the background service is enqueued if enabled in settings
|
||||
Future<bool> resumeServiceIfEnabled() async {
|
||||
return await isBackgroundBackupEnabled() &&
|
||||
await startService(keepExisting: true);
|
||||
}
|
||||
|
||||
/// Enqueues the background service
|
||||
Future<bool> startService({
|
||||
bool immediate = false,
|
||||
bool keepExisting = false,
|
||||
bool requireUnmetered = true,
|
||||
bool requireCharging = false,
|
||||
}) async {
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!_isForegroundInitialized) {
|
||||
await _initialize();
|
||||
}
|
||||
final String title =
|
||||
"backup_background_service_default_notification".tr();
|
||||
final bool ok = await _foregroundChannel.invokeMethod(
|
||||
'start',
|
||||
[immediate, keepExisting, requireUnmetered, requireCharging, title],
|
||||
);
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels the background service (if currently running) and removes it from work queue
|
||||
Future<bool> stopService() async {
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!_isForegroundInitialized) {
|
||||
await _initialize();
|
||||
}
|
||||
final ok = await _foregroundChannel.invokeMethod('stop');
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the background service is enabled
|
||||
Future<bool> isBackgroundBackupEnabled() async {
|
||||
if (!Platform.isAndroid) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!_isForegroundInitialized) {
|
||||
await _initialize();
|
||||
}
|
||||
return await _foregroundChannel.invokeMethod("isEnabled");
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens an activity to let the user disable battery optimizations for Immich
|
||||
Future<bool> disableBatteryOptimizations() async {
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!_isForegroundInitialized) {
|
||||
await _initialize();
|
||||
}
|
||||
final String message =
|
||||
"backup_background_service_disable_battery_optimizations".tr();
|
||||
return await _foregroundChannel.invokeMethod(
|
||||
'disableBatteryOptimizations',
|
||||
message,
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the notification shown by the background service
|
||||
Future<bool> updateNotification({
|
||||
String title = "Immich",
|
||||
String? content,
|
||||
}) async {
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (_isBackgroundInitialized) {
|
||||
return await _backgroundChannel
|
||||
.invokeMethod('updateNotification', [title, content]);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[updateNotification] failed to communicate with plugin");
|
||||
}
|
||||
return Future.value(false);
|
||||
}
|
||||
|
||||
/// Shows a new priority notification
|
||||
Future<bool> showErrorNotification(
|
||||
String title,
|
||||
String content,
|
||||
) async {
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (_isBackgroundInitialized) {
|
||||
return await _backgroundChannel
|
||||
.invokeMethod('showError', [title, content]);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[showErrorNotification] failed to communicate with plugin");
|
||||
}
|
||||
return Future.value(false);
|
||||
}
|
||||
|
||||
/// await to ensure this thread (foreground or background) has exclusive access
|
||||
Future<bool> acquireLock() async {
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
final int lockTime = Timeline.now;
|
||||
_wantsLockTime = lockTime;
|
||||
final ReceivePort rp = ReceivePort(_portNameLock);
|
||||
_rp = rp;
|
||||
final SendPort sp = rp.sendPort;
|
||||
|
||||
while (!IsolateNameServer.registerPortWithName(sp, _portNameLock)) {
|
||||
try {
|
||||
await _checkLockReleasedWithHeartbeat(lockTime);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
if (_wantsLockTime != lockTime) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_hasLock = true;
|
||||
rp.listen(_heartbeatListener);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _checkLockReleasedWithHeartbeat(final int lockTime) async {
|
||||
SendPort? other = IsolateNameServer.lookupPortByName(_portNameLock);
|
||||
if (other != null) {
|
||||
final ReceivePort tempRp = ReceivePort();
|
||||
final SendPort tempSp = tempRp.sendPort;
|
||||
final bs = tempRp.asBroadcastStream();
|
||||
while (_wantsLockTime == lockTime) {
|
||||
other.send(tempSp);
|
||||
final dynamic answer = await bs.first
|
||||
.timeout(const Duration(seconds: 5), onTimeout: () => null);
|
||||
if (_wantsLockTime != lockTime) {
|
||||
break;
|
||||
}
|
||||
if (answer == null) {
|
||||
// other isolate failed to answer, assuming it exited without releasing the lock
|
||||
if (other == IsolateNameServer.lookupPortByName(_portNameLock)) {
|
||||
IsolateNameServer.removePortNameMapping(_portNameLock);
|
||||
}
|
||||
break;
|
||||
} else if (answer == true) {
|
||||
// other isolate released the lock
|
||||
break;
|
||||
} else if (answer == false) {
|
||||
// other isolate is still active
|
||||
}
|
||||
final dynamic isFinished = await bs.first
|
||||
.timeout(const Duration(seconds: 5), onTimeout: () => false);
|
||||
if (isFinished == true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tempRp.close();
|
||||
}
|
||||
}
|
||||
|
||||
void _heartbeatListener(dynamic msg) {
|
||||
if (msg is SendPort) {
|
||||
_waitingIsolate = msg;
|
||||
msg.send(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// releases the exclusive access lock
|
||||
void releaseLock() {
|
||||
if (!Platform.isAndroid) {
|
||||
return;
|
||||
}
|
||||
_wantsLockTime = 0;
|
||||
if (_hasLock) {
|
||||
IsolateNameServer.removePortNameMapping(_portNameLock);
|
||||
_waitingIsolate?.send(true);
|
||||
_waitingIsolate = null;
|
||||
_hasLock = false;
|
||||
}
|
||||
_rp?.close();
|
||||
_rp = null;
|
||||
}
|
||||
|
||||
void _setupBackgroundCallHandler() {
|
||||
_backgroundChannel.setMethodCallHandler(_callHandler);
|
||||
_isBackgroundInitialized = true;
|
||||
_backgroundChannel.invokeMethod('initialized');
|
||||
}
|
||||
|
||||
Future<bool> _callHandler(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case "onAssetsChanged":
|
||||
final Future<bool> translationsLoaded = loadTranslations();
|
||||
try {
|
||||
final bool hasAccess = await acquireLock();
|
||||
if (!hasAccess) {
|
||||
debugPrint("[_callHandler] could acquire lock, exiting");
|
||||
return false;
|
||||
}
|
||||
await translationsLoaded;
|
||||
return await _onAssetsChanged();
|
||||
} catch (error) {
|
||||
debugPrint(error.toString());
|
||||
return false;
|
||||
} finally {
|
||||
await Hive.close();
|
||||
releaseLock();
|
||||
}
|
||||
case "systemStop":
|
||||
_canceledBySystem = true;
|
||||
_cancellationToken?.cancel();
|
||||
return true;
|
||||
default:
|
||||
debugPrint("Unknown method ${call.method}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _onAssetsChanged() async {
|
||||
await Hive.initFlutter();
|
||||
|
||||
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
||||
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||
await Hive.openBox(userInfoBox);
|
||||
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
||||
|
||||
ApiService apiService = ApiService();
|
||||
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
||||
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
|
||||
BackupService backupService = BackupService(apiService);
|
||||
|
||||
final Box<HiveBackupAlbums> box =
|
||||
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
||||
if (backupAlbumInfo == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await PhotoManager.setIgnorePermissionCheck(true);
|
||||
|
||||
if (_canceledBySystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final List<AssetEntity> toUpload =
|
||||
await backupService.getAssetsToBackup(backupAlbumInfo);
|
||||
|
||||
if (_canceledBySystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toUpload.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
_cancellationToken = CancellationToken();
|
||||
final bool ok = await backupService.backupAsset(
|
||||
toUpload,
|
||||
_cancellationToken!,
|
||||
_onAssetUploaded,
|
||||
_onProgress,
|
||||
_onSetCurrentBackupAsset,
|
||||
_onBackupError,
|
||||
);
|
||||
if (ok) {
|
||||
await box.put(
|
||||
backupInfoKey,
|
||||
backupAlbumInfo,
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
||||
debugPrint("Uploaded $deviceAssetId from $deviceId");
|
||||
}
|
||||
|
||||
void _onProgress(int sent, int total) {}
|
||||
|
||||
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||
showErrorNotification(
|
||||
"backup_background_service_upload_failure_notification"
|
||||
.tr(args: [errorAssetInfo.fileName]),
|
||||
errorAssetInfo.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||
updateNotification(
|
||||
title: "backup_background_service_in_progress_notification".tr(),
|
||||
content: "backup_background_service_current_upload_notification"
|
||||
.tr(args: [currentUploadAsset.fileName]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
||||
void _nativeEntry() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
BackgroundService backgroundService = BackgroundService();
|
||||
backgroundService._setupBackgroundCallHandler();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:easy_localization/src/asset_loader.dart';
|
||||
import 'package:easy_localization/src/easy_localization_controller.dart';
|
||||
import 'package:easy_localization/src/localization.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
|
||||
/// Workaround to manually load translations in another Isolate
|
||||
Future<bool> loadTranslations() async {
|
||||
await EasyLocalizationController.initEasyLocation();
|
||||
|
||||
final controller = EasyLocalizationController(
|
||||
supportedLocales: locales,
|
||||
useFallbackTranslations: true,
|
||||
saveLocale: true,
|
||||
assetLoader: const RootBundleAssetLoader(),
|
||||
path: translationsPath,
|
||||
useOnlyLangCode: false,
|
||||
onLoadError: (e) => debugPrint(e.toString()),
|
||||
fallbackLocale: locales.first,
|
||||
);
|
||||
|
||||
await controller.loadTranslations();
|
||||
|
||||
return Localization.load(controller.locale,
|
||||
translations: controller.translations,
|
||||
fallbackTranslations: controller.fallbackTranslations);
|
||||
}
|
||||
@@ -4,35 +4,45 @@ import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class AvailableAlbum {
|
||||
final AssetPathEntity albumEntity;
|
||||
final DateTime? lastBackup;
|
||||
final Uint8List? thumbnailData;
|
||||
AvailableAlbum({
|
||||
required this.albumEntity,
|
||||
this.lastBackup,
|
||||
this.thumbnailData,
|
||||
});
|
||||
|
||||
AvailableAlbum copyWith({
|
||||
AssetPathEntity? albumEntity,
|
||||
DateTime? lastBackup,
|
||||
Uint8List? thumbnailData,
|
||||
}) {
|
||||
return AvailableAlbum(
|
||||
albumEntity: albumEntity ?? this.albumEntity,
|
||||
lastBackup: lastBackup ?? this.lastBackup,
|
||||
thumbnailData: thumbnailData ?? this.thumbnailData,
|
||||
);
|
||||
}
|
||||
|
||||
String get name => albumEntity.name;
|
||||
|
||||
int get assetCount => albumEntity.assetCount;
|
||||
|
||||
String get id => albumEntity.id;
|
||||
|
||||
bool get isAll => albumEntity.isAll;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AvailableAlbum(albumEntity: $albumEntity, thumbnailData: $thumbnailData)';
|
||||
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is AvailableAlbum &&
|
||||
other.albumEntity == albumEntity &&
|
||||
other.thumbnailData == thumbnailData;
|
||||
return other is AvailableAlbum && other.albumEntity == albumEntity;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => albumEntity.hashCode ^ thumbnailData.hashCode;
|
||||
int get hashCode => albumEntity.hashCode;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||
|
||||
enum BackUpProgressEnum { idle, inProgress, done }
|
||||
enum BackUpProgressEnum { idle, inProgress, inBackground, done }
|
||||
|
||||
class BackUpState {
|
||||
// enum
|
||||
@@ -15,11 +15,14 @@ class BackUpState {
|
||||
final double progressInPercentage;
|
||||
final CancellationToken cancelToken;
|
||||
final ServerInfoResponseDto serverInfo;
|
||||
final bool backgroundBackup;
|
||||
final bool backupRequireWifi;
|
||||
final bool backupRequireCharging;
|
||||
|
||||
/// All available albums on the device
|
||||
final List<AvailableAlbum> availableAlbums;
|
||||
final Set<AssetPathEntity> selectedBackupAlbums;
|
||||
final Set<AssetPathEntity> excludedBackupAlbums;
|
||||
final Set<AvailableAlbum> selectedBackupAlbums;
|
||||
final Set<AvailableAlbum> excludedBackupAlbums;
|
||||
|
||||
/// Assets that are not overlapping in selected backup albums and excluded backup albums
|
||||
final Set<AssetEntity> allUniqueAssets;
|
||||
@@ -36,6 +39,9 @@ class BackUpState {
|
||||
required this.progressInPercentage,
|
||||
required this.cancelToken,
|
||||
required this.serverInfo,
|
||||
required this.backgroundBackup,
|
||||
required this.backupRequireWifi,
|
||||
required this.backupRequireCharging,
|
||||
required this.availableAlbums,
|
||||
required this.selectedBackupAlbums,
|
||||
required this.excludedBackupAlbums,
|
||||
@@ -50,9 +56,12 @@ class BackUpState {
|
||||
double? progressInPercentage,
|
||||
CancellationToken? cancelToken,
|
||||
ServerInfoResponseDto? serverInfo,
|
||||
bool? backgroundBackup,
|
||||
bool? backupRequireWifi,
|
||||
bool? backupRequireCharging,
|
||||
List<AvailableAlbum>? availableAlbums,
|
||||
Set<AssetPathEntity>? selectedBackupAlbums,
|
||||
Set<AssetPathEntity>? excludedBackupAlbums,
|
||||
Set<AvailableAlbum>? selectedBackupAlbums,
|
||||
Set<AvailableAlbum>? excludedBackupAlbums,
|
||||
Set<AssetEntity>? allUniqueAssets,
|
||||
Set<String>? selectedAlbumsBackupAssetsIds,
|
||||
CurrentUploadAsset? currentUploadAsset,
|
||||
@@ -63,6 +72,10 @@ class BackUpState {
|
||||
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
|
||||
cancelToken: cancelToken ?? this.cancelToken,
|
||||
serverInfo: serverInfo ?? this.serverInfo,
|
||||
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
|
||||
backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi,
|
||||
backupRequireCharging:
|
||||
backupRequireCharging ?? this.backupRequireCharging,
|
||||
availableAlbums: availableAlbums ?? this.availableAlbums,
|
||||
selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
|
||||
excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
|
||||
@@ -75,7 +88,7 @@ class BackUpState {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
|
||||
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -89,6 +102,9 @@ class BackUpState {
|
||||
other.progressInPercentage == progressInPercentage &&
|
||||
other.cancelToken == cancelToken &&
|
||||
other.serverInfo == serverInfo &&
|
||||
other.backgroundBackup == backgroundBackup &&
|
||||
other.backupRequireWifi == backupRequireWifi &&
|
||||
other.backupRequireCharging == backupRequireCharging &&
|
||||
collectionEquals(other.availableAlbums, availableAlbums) &&
|
||||
collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
|
||||
collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
|
||||
@@ -107,6 +123,9 @@ class BackUpState {
|
||||
progressInPercentage.hashCode ^
|
||||
cancelToken.hashCode ^
|
||||
serverInfo.hashCode ^
|
||||
backgroundBackup.hashCode ^
|
||||
backupRequireWifi.hashCode ^
|
||||
backupRequireCharging.hashCode ^
|
||||
availableAlbums.hashCode ^
|
||||
selectedBackupAlbums.hashCode ^
|
||||
excludedBackupAlbums.hashCode ^
|
||||
|
||||
@@ -13,9 +13,17 @@ class HiveBackupAlbums {
|
||||
@HiveField(1)
|
||||
List<String> excludedAlbumsIds;
|
||||
|
||||
@HiveField(2, defaultValue: [])
|
||||
List<DateTime> lastSelectedBackupTime;
|
||||
|
||||
@HiveField(3, defaultValue: [])
|
||||
List<DateTime> lastExcludedBackupTime;
|
||||
|
||||
HiveBackupAlbums({
|
||||
required this.selectedAlbumIds,
|
||||
required this.excludedAlbumsIds,
|
||||
required this.lastSelectedBackupTime,
|
||||
required this.lastExcludedBackupTime,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -25,10 +33,16 @@ class HiveBackupAlbums {
|
||||
HiveBackupAlbums copyWith({
|
||||
List<String>? selectedAlbumIds,
|
||||
List<String>? excludedAlbumsIds,
|
||||
List<DateTime>? lastSelectedBackupTime,
|
||||
List<DateTime>? lastExcludedBackupTime,
|
||||
}) {
|
||||
return HiveBackupAlbums(
|
||||
selectedAlbumIds: selectedAlbumIds ?? this.selectedAlbumIds,
|
||||
excludedAlbumsIds: excludedAlbumsIds ?? this.excludedAlbumsIds,
|
||||
lastSelectedBackupTime:
|
||||
lastSelectedBackupTime ?? this.lastSelectedBackupTime,
|
||||
lastExcludedBackupTime:
|
||||
lastExcludedBackupTime ?? this.lastExcludedBackupTime,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +51,8 @@ class HiveBackupAlbums {
|
||||
|
||||
result.addAll({'selectedAlbumIds': selectedAlbumIds});
|
||||
result.addAll({'excludedAlbumsIds': excludedAlbumsIds});
|
||||
result.addAll({'lastSelectedBackupTime': lastSelectedBackupTime});
|
||||
result.addAll({'lastExcludedBackupTime': lastExcludedBackupTime});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -45,6 +61,10 @@ class HiveBackupAlbums {
|
||||
return HiveBackupAlbums(
|
||||
selectedAlbumIds: List<String>.from(map['selectedAlbumIds']),
|
||||
excludedAlbumsIds: List<String>.from(map['excludedAlbumsIds']),
|
||||
lastSelectedBackupTime:
|
||||
List<DateTime>.from(map['lastSelectedBackupTime']),
|
||||
lastExcludedBackupTime:
|
||||
List<DateTime>.from(map['lastExcludedBackupTime']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,9 +80,15 @@ class HiveBackupAlbums {
|
||||
|
||||
return other is HiveBackupAlbums &&
|
||||
listEquals(other.selectedAlbumIds, selectedAlbumIds) &&
|
||||
listEquals(other.excludedAlbumsIds, excludedAlbumsIds);
|
||||
listEquals(other.excludedAlbumsIds, excludedAlbumsIds) &&
|
||||
listEquals(other.lastSelectedBackupTime, lastSelectedBackupTime) &&
|
||||
listEquals(other.lastExcludedBackupTime, lastExcludedBackupTime);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => selectedAlbumIds.hashCode ^ excludedAlbumsIds.hashCode;
|
||||
int get hashCode =>
|
||||
selectedAlbumIds.hashCode ^
|
||||
excludedAlbumsIds.hashCode ^
|
||||
lastSelectedBackupTime.hashCode ^
|
||||
lastExcludedBackupTime.hashCode;
|
||||
}
|
||||
|
||||
@@ -19,17 +19,25 @@ class HiveBackupAlbumsAdapter extends TypeAdapter<HiveBackupAlbums> {
|
||||
return HiveBackupAlbums(
|
||||
selectedAlbumIds: (fields[0] as List).cast<String>(),
|
||||
excludedAlbumsIds: (fields[1] as List).cast<String>(),
|
||||
lastSelectedBackupTime:
|
||||
fields[2] == null ? [] : (fields[2] as List).cast<DateTime>(),
|
||||
lastExcludedBackupTime:
|
||||
fields[3] == null ? [] : (fields[3] as List).cast<DateTime>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, HiveBackupAlbums obj) {
|
||||
writer
|
||||
..writeByte(2)
|
||||
..writeByte(4)
|
||||
..writeByte(0)
|
||||
..write(obj.selectedAlbumIds)
|
||||
..writeByte(1)
|
||||
..write(obj.excludedAlbumsIds);
|
||||
..write(obj.excludedAlbumsIds)
|
||||
..writeByte(2)
|
||||
..write(obj.lastSelectedBackupTime)
|
||||
..writeByte(3)
|
||||
..write(obj.lastExcludedBackupTime);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
@@ -9,9 +11,11 @@ import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.d
|
||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/server_info.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
@@ -21,6 +25,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
this._backupService,
|
||||
this._serverInfoService,
|
||||
this._authState,
|
||||
this._backgroundService,
|
||||
this.ref,
|
||||
) : super(
|
||||
BackUpState(
|
||||
@@ -28,6 +33,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
allAssetsInDatabase: const [],
|
||||
progressInPercentage: 0,
|
||||
cancelToken: CancellationToken(),
|
||||
backgroundBackup: false,
|
||||
backupRequireWifi: true,
|
||||
backupRequireCharging: false,
|
||||
serverInfo: ServerInfoResponseDto(
|
||||
diskAvailable: "0",
|
||||
diskAvailableRaw: 0,
|
||||
@@ -56,6 +64,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
final BackupService _backupService;
|
||||
final ServerInfoService _serverInfoService;
|
||||
final AuthenticationState _authState;
|
||||
final BackgroundService _backgroundService;
|
||||
final Ref ref;
|
||||
|
||||
///
|
||||
@@ -66,7 +75,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
/// We have method to include and exclude albums
|
||||
/// The total unique assets will be used for backing mechanism
|
||||
///
|
||||
void addAlbumForBackup(AssetPathEntity album) {
|
||||
void addAlbumForBackup(AvailableAlbum album) {
|
||||
if (state.excludedBackupAlbums.contains(album)) {
|
||||
removeExcludedAlbumForBackup(album);
|
||||
}
|
||||
@@ -76,7 +85,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
_updateBackupAssetCount();
|
||||
}
|
||||
|
||||
void addExcludedAlbumForBackup(AssetPathEntity album) {
|
||||
void addExcludedAlbumForBackup(AvailableAlbum album) {
|
||||
if (state.selectedBackupAlbums.contains(album)) {
|
||||
removeAlbumForBackup(album);
|
||||
}
|
||||
@@ -85,8 +94,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
_updateBackupAssetCount();
|
||||
}
|
||||
|
||||
void removeAlbumForBackup(AssetPathEntity album) {
|
||||
Set<AssetPathEntity> currentSelectedAlbums = state.selectedBackupAlbums;
|
||||
void removeAlbumForBackup(AvailableAlbum album) {
|
||||
Set<AvailableAlbum> currentSelectedAlbums = state.selectedBackupAlbums;
|
||||
|
||||
currentSelectedAlbums.removeWhere((a) => a == album);
|
||||
|
||||
@@ -94,8 +103,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
_updateBackupAssetCount();
|
||||
}
|
||||
|
||||
void removeExcludedAlbumForBackup(AssetPathEntity album) {
|
||||
Set<AssetPathEntity> currentExcludedAlbums = state.excludedBackupAlbums;
|
||||
void removeExcludedAlbumForBackup(AvailableAlbum album) {
|
||||
Set<AvailableAlbum> currentExcludedAlbums = state.excludedBackupAlbums;
|
||||
|
||||
currentExcludedAlbums.removeWhere((a) => a == album);
|
||||
|
||||
@@ -103,6 +112,50 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
_updateBackupAssetCount();
|
||||
}
|
||||
|
||||
void configureBackgroundBackup({
|
||||
bool? enabled,
|
||||
bool? requireWifi,
|
||||
bool? requireCharging,
|
||||
required void Function(String msg) onError,
|
||||
}) async {
|
||||
assert(enabled != null || requireWifi != null || requireCharging != null);
|
||||
if (Platform.isAndroid) {
|
||||
final bool wasEnabled = state.backgroundBackup;
|
||||
final bool wasWifi = state.backupRequireWifi;
|
||||
final bool wasCharing = state.backupRequireCharging;
|
||||
state = state.copyWith(
|
||||
backgroundBackup: enabled,
|
||||
backupRequireWifi: requireWifi,
|
||||
backupRequireCharging: requireCharging,
|
||||
);
|
||||
|
||||
if (state.backgroundBackup) {
|
||||
if (!wasEnabled) {
|
||||
await _backgroundService.disableBatteryOptimizations();
|
||||
}
|
||||
final bool success = await _backgroundService.stopService() &&
|
||||
await _backgroundService.startService(
|
||||
requireUnmetered: state.backupRequireWifi,
|
||||
requireCharging: state.backupRequireCharging,
|
||||
);
|
||||
if (!success) {
|
||||
state = state.copyWith(
|
||||
backgroundBackup: wasEnabled,
|
||||
backupRequireWifi: wasWifi,
|
||||
backupRequireCharging: wasCharing,
|
||||
);
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
}
|
||||
} else {
|
||||
final bool success = await _backgroundService.stopService();
|
||||
if (!success) {
|
||||
state = state.copyWith(backgroundBackup: wasEnabled);
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
/// Get all album on the device
|
||||
/// Get all selected and excluded album from the user's persistent storage
|
||||
@@ -144,6 +197,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
defaultValue: HiveBackupAlbums(
|
||||
selectedAlbumIds: [],
|
||||
excludedAlbumsIds: [],
|
||||
lastSelectedBackupTime: [],
|
||||
lastExcludedBackupTime: [],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -173,6 +228,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
HiveBackupAlbums(
|
||||
selectedAlbumIds: [albumHasAllAssets.id],
|
||||
excludedAlbumsIds: [],
|
||||
lastSelectedBackupTime: [
|
||||
DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)
|
||||
],
|
||||
lastExcludedBackupTime: [],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -181,19 +240,37 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
// Generate AssetPathEntity from id to add to local state
|
||||
try {
|
||||
for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) {
|
||||
var albumAsset = await AssetPathEntity.fromId(selectedAlbumId);
|
||||
state = state.copyWith(
|
||||
selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset},
|
||||
Set<AvailableAlbum> selectedAlbums = {};
|
||||
for (var i = 0; i < backupAlbumInfo!.selectedAlbumIds.length; i++) {
|
||||
var albumAsset =
|
||||
await AssetPathEntity.fromId(backupAlbumInfo.selectedAlbumIds[i]);
|
||||
selectedAlbums.add(
|
||||
AvailableAlbum(
|
||||
albumEntity: albumAsset,
|
||||
lastBackup: backupAlbumInfo.lastSelectedBackupTime.length > i
|
||||
? backupAlbumInfo.lastSelectedBackupTime[i]
|
||||
: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) {
|
||||
var albumAsset = await AssetPathEntity.fromId(excludedAlbumId);
|
||||
state = state.copyWith(
|
||||
excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset},
|
||||
Set<AvailableAlbum> excludedAlbums = {};
|
||||
for (var i = 0; i < backupAlbumInfo.excludedAlbumsIds.length; i++) {
|
||||
var albumAsset =
|
||||
await AssetPathEntity.fromId(backupAlbumInfo.excludedAlbumsIds[i]);
|
||||
excludedAlbums.add(
|
||||
AvailableAlbum(
|
||||
albumEntity: albumAsset,
|
||||
lastBackup: backupAlbumInfo.lastExcludedBackupTime.length > i
|
||||
? backupAlbumInfo.lastExcludedBackupTime[i]
|
||||
: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
state = state.copyWith(
|
||||
selectedBackupAlbums: selectedAlbums,
|
||||
excludedBackupAlbums: excludedAlbums,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint("[ERROR] Failed to generate album from id $e");
|
||||
}
|
||||
@@ -209,14 +286,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||
|
||||
for (var album in state.selectedBackupAlbums) {
|
||||
var assets =
|
||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
var assets = await album.albumEntity
|
||||
.getAssetListRange(start: 0, end: album.assetCount);
|
||||
assetsFromSelectedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
for (var album in state.excludedBackupAlbums) {
|
||||
var assets =
|
||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
var assets = await album.albumEntity
|
||||
.getAssetListRange(start: 0, end: album.assetCount);
|
||||
assetsFromExcludedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
@@ -263,12 +340,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
/// and then update the UI according to those information
|
||||
///
|
||||
Future<void> getBackupInfo() async {
|
||||
await Future.wait([
|
||||
_getBackupAlbumsInfo(),
|
||||
_updateServerInfo(),
|
||||
]);
|
||||
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||
state = state.copyWith(backgroundBackup: isEnabled);
|
||||
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
||||
await Future.wait([
|
||||
_getBackupAlbumsInfo(),
|
||||
_updateServerInfo(),
|
||||
]);
|
||||
|
||||
await _updateBackupAssetCount();
|
||||
await _updateBackupAssetCount();
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
@@ -276,6 +357,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
/// Hive database
|
||||
///
|
||||
void _updatePersistentAlbumsSelection() {
|
||||
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
Box<HiveBackupAlbums> backupAlbumInfoBox =
|
||||
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
backupAlbumInfoBox.put(
|
||||
@@ -283,6 +365,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
HiveBackupAlbums(
|
||||
selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(),
|
||||
excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(),
|
||||
lastSelectedBackupTime: state.selectedBackupAlbums
|
||||
.map((e) => e.lastBackup ?? epoch)
|
||||
.toList(),
|
||||
lastExcludedBackupTime: state.excludedBackupAlbums
|
||||
.map((e) => e.lastBackup ?? epoch)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -290,7 +378,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
///
|
||||
/// Invoke backup process
|
||||
///
|
||||
void startBackupProcess() async {
|
||||
Future<void> startBackupProcess() async {
|
||||
assert(state.backupProgress == BackUpProgressEnum.idle);
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
|
||||
|
||||
await getBackupInfo();
|
||||
@@ -318,7 +407,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
// Perform Backup
|
||||
state = state.copyWith(cancelToken: CancellationToken());
|
||||
_backupService.backupAsset(
|
||||
await _backupService.backupAsset(
|
||||
assetsWillBeBackup,
|
||||
state.cancelToken,
|
||||
_onAssetUploaded,
|
||||
@@ -326,6 +415,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
_onSetCurrentBackupAsset,
|
||||
_onBackupError,
|
||||
);
|
||||
await _notifyBackgroundServiceCanRun();
|
||||
} else {
|
||||
PhotoManager.openSetting();
|
||||
}
|
||||
@@ -340,6 +430,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
|
||||
void cancelBackup() {
|
||||
if (state.backupProgress != BackUpProgressEnum.inProgress) {
|
||||
_notifyBackgroundServiceCanRun();
|
||||
}
|
||||
state.cancelToken.cancel();
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
@@ -359,10 +452,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
if (state.allUniqueAssets.length -
|
||||
state.selectedAlbumsBackupAssetsIds.length ==
|
||||
0) {
|
||||
final latestAssetBackup =
|
||||
state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce(
|
||||
(v, e) => e.isAfter(v) ? e : v,
|
||||
);
|
||||
state = state.copyWith(
|
||||
selectedBackupAlbums: state.selectedBackupAlbums
|
||||
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
|
||||
.toSet(),
|
||||
excludedBackupAlbums: state.excludedBackupAlbums
|
||||
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
|
||||
.toSet(),
|
||||
backupProgress: BackUpProgressEnum.done,
|
||||
progressInPercentage: 0.0,
|
||||
);
|
||||
_updatePersistentAlbumsSelection();
|
||||
}
|
||||
|
||||
_updateServerInfo();
|
||||
@@ -385,7 +489,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
}
|
||||
|
||||
void resumeBackup() {
|
||||
Future<void> _resumeBackup() async {
|
||||
// Check if user is login
|
||||
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
||||
|
||||
@@ -404,13 +508,91 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.backupProgress == BackUpProgressEnum.inBackground) {
|
||||
debugPrint("[resumeBackup] Background backup is running - abort");
|
||||
return;
|
||||
}
|
||||
|
||||
// Run backup
|
||||
debugPrint("[resumeBackup] Start back up");
|
||||
startBackupProcess();
|
||||
await startBackupProcess();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> resumeBackup() async {
|
||||
if (Platform.isAndroid) {
|
||||
// assumes the background service is currently running
|
||||
// if true, waits until it has stopped to update the app state from HiveDB
|
||||
// before actually resuming backup by calling the internal `_resumeBackup`
|
||||
final BackUpProgressEnum previous = state.backupProgress;
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
|
||||
final bool hasLock = await _backgroundService.acquireLock();
|
||||
if (!hasLock) {
|
||||
return;
|
||||
}
|
||||
Box<HiveBackupAlbums> box =
|
||||
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
HiveBackupAlbums? albums = box.get(backupInfoKey);
|
||||
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
|
||||
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
|
||||
if (albums != null) {
|
||||
selectedAlbums = _updateAlbumsBackupTime(
|
||||
selectedAlbums,
|
||||
albums.selectedAlbumIds,
|
||||
albums.lastSelectedBackupTime,
|
||||
);
|
||||
excludedAlbums = _updateAlbumsBackupTime(
|
||||
excludedAlbums,
|
||||
albums.excludedAlbumsIds,
|
||||
albums.lastExcludedBackupTime,
|
||||
);
|
||||
}
|
||||
state = state.copyWith(
|
||||
backupProgress: previous,
|
||||
selectedBackupAlbums: selectedAlbums,
|
||||
excludedBackupAlbums: excludedAlbums,
|
||||
);
|
||||
}
|
||||
return _resumeBackup();
|
||||
}
|
||||
|
||||
Set<AvailableAlbum> _updateAlbumsBackupTime(
|
||||
Set<AvailableAlbum> albums,
|
||||
List<String> ids,
|
||||
List<DateTime> times,
|
||||
) {
|
||||
Set<AvailableAlbum> result = {};
|
||||
for (int i = 0; i < ids.length; i++) {
|
||||
try {
|
||||
AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
|
||||
result.add(a.copyWith(lastBackup: times[i]));
|
||||
} on StateError {
|
||||
debugPrint("[_updateAlbumBackupTime] failed to find album in state");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _notifyBackgroundServiceCanRun() async {
|
||||
const allowedStates = [
|
||||
AppStateEnum.inactive,
|
||||
AppStateEnum.paused,
|
||||
AppStateEnum.detached,
|
||||
];
|
||||
if (Platform.isAndroid &&
|
||||
allowedStates.contains(ref.read(appStateProvider.notifier).state)) {
|
||||
try {
|
||||
if (Hive.isBoxOpen(hiveBackupInfoBox)) {
|
||||
await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||
}
|
||||
_backgroundService.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final backupProvider =
|
||||
@@ -419,6 +601,7 @@ final backupProvider =
|
||||
ref.watch(backupServiceProvider),
|
||||
ref.watch(serverInfoServiceProvider),
|
||||
ref.watch(authenticationProvider),
|
||||
ref.watch(backgroundServiceProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -9,6 +10,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/files_helper.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -39,8 +41,141 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
backupAsset(
|
||||
Set<AssetEntity> assetList,
|
||||
/// Returns all assets to backup from the backup info taking into account the
|
||||
/// time of the last successfull backup per album
|
||||
Future<List<AssetEntity>> getAssetsToBackup(
|
||||
HiveBackupAlbums backupAlbumInfo,
|
||||
) async {
|
||||
final List<AssetEntity> candidates =
|
||||
await _buildUploadCandidates(backupAlbumInfo);
|
||||
|
||||
final List<AssetEntity> toUpload = candidates.isEmpty
|
||||
? []
|
||||
: await _removeAlreadyUploadedAssets(candidates);
|
||||
return toUpload;
|
||||
}
|
||||
|
||||
Future<List<AssetEntity>> _buildUploadCandidates(
|
||||
HiveBackupAlbums backupAlbums,
|
||||
) async {
|
||||
final filter = FilterOptionGroup(
|
||||
containsPathModified: true,
|
||||
orders: [const OrderOption(type: OrderOptionType.updateDate)],
|
||||
);
|
||||
final now = DateTime.now();
|
||||
final List<AssetPathEntity?> selectedAlbums =
|
||||
await _loadAlbumsWithTimeFilter(
|
||||
backupAlbums.selectedAlbumIds,
|
||||
backupAlbums.lastSelectedBackupTime,
|
||||
filter,
|
||||
now,
|
||||
);
|
||||
if (selectedAlbums.every((e) => e == null)) {
|
||||
return [];
|
||||
}
|
||||
final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
|
||||
if (allIdx != -1) {
|
||||
final List<AssetPathEntity?> excludedAlbums =
|
||||
await _loadAlbumsWithTimeFilter(
|
||||
backupAlbums.excludedAlbumsIds,
|
||||
backupAlbums.lastExcludedBackupTime,
|
||||
filter,
|
||||
now,
|
||||
);
|
||||
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedAlbums.slice(allIdx, allIdx + 1),
|
||||
backupAlbums.lastSelectedBackupTime.slice(allIdx, allIdx + 1),
|
||||
now,
|
||||
);
|
||||
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
|
||||
excludedAlbums,
|
||||
backupAlbums.lastExcludedBackupTime,
|
||||
now,
|
||||
);
|
||||
return toAdd.toSet().difference(toRemove.toSet()).toList();
|
||||
} else {
|
||||
return await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedAlbums,
|
||||
backupAlbums.lastSelectedBackupTime,
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
|
||||
List<String> albumIds,
|
||||
List<DateTime> lastBackups,
|
||||
FilterOptionGroup filter,
|
||||
DateTime now,
|
||||
) async {
|
||||
List<AssetPathEntity?> result = List.filled(albumIds.length, null);
|
||||
for (int i = 0; i < albumIds.length; i++) {
|
||||
try {
|
||||
final AssetPathEntity album =
|
||||
await AssetPathEntity.obtainPathFromProperties(
|
||||
id: albumIds[i],
|
||||
optionGroup: filter.copyWith(
|
||||
updateTimeCond: DateTimeCond(
|
||||
// subtract 2 seconds to prevent missing assets due to rounding issues
|
||||
min: lastBackups[i].subtract(const Duration(seconds: 2)),
|
||||
max: now,
|
||||
),
|
||||
),
|
||||
maxDateTimeToNow: false,
|
||||
);
|
||||
result[i] = album;
|
||||
} on StateError {
|
||||
// either there are no assets matching the filter criteria OR the album no longer exists
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
|
||||
List<AssetPathEntity?> albums,
|
||||
List<DateTime> lastBackup,
|
||||
DateTime now,
|
||||
) async {
|
||||
List<AssetEntity> result = [];
|
||||
for (int i = 0; i < albums.length; i++) {
|
||||
final AssetPathEntity? a = albums[i];
|
||||
if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) {
|
||||
result.addAll(await a.getAssetListRange(start: 0, end: a.assetCount));
|
||||
lastBackup[i] = now;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<AssetEntity>> _removeAlreadyUploadedAssets(
|
||||
List<AssetEntity> candidates,
|
||||
) async {
|
||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
if (candidates.length < 10) {
|
||||
final List<CheckDuplicateAssetResponseDto?> duplicateResponse =
|
||||
await Future.wait(
|
||||
candidates.map(
|
||||
(e) => _apiService.assetApi.checkDuplicateAsset(
|
||||
CheckDuplicateAssetDto(deviceAssetId: e.id, deviceId: deviceId),
|
||||
),
|
||||
),
|
||||
);
|
||||
return candidates
|
||||
.whereIndexed((i, e) => duplicateResponse[i]?.isExist == false)
|
||||
.toList();
|
||||
} else {
|
||||
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
|
||||
|
||||
if (allAssetsInDatabase == null) {
|
||||
return candidates;
|
||||
}
|
||||
final Set<String> inDb = allAssetsInDatabase.toSet();
|
||||
return candidates.whereNot((e) => inDb.contains(e.id)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> backupAsset(
|
||||
Iterable<AssetEntity> assetList,
|
||||
http.CancellationToken cancelToken,
|
||||
Function(String, String) singleAssetDoneCb,
|
||||
Function(int, int) uploadProgressCb,
|
||||
@@ -50,6 +185,7 @@ class BackupService {
|
||||
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
File? file;
|
||||
bool anyErrors = false;
|
||||
|
||||
for (var entity in assetList) {
|
||||
try {
|
||||
@@ -134,9 +270,10 @@ class BackupService {
|
||||
}
|
||||
} on http.CancelledException {
|
||||
debugPrint("Backup was cancelled by the user");
|
||||
return;
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint("ERROR backupAsset: ${e.toString()}");
|
||||
anyErrors = true;
|
||||
continue;
|
||||
} finally {
|
||||
if (Platform.isIOS) {
|
||||
@@ -144,6 +281,7 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
}
|
||||
return !anyErrors;
|
||||
}
|
||||
|
||||
String _getAssetType(AssetType assetType) {
|
||||
|
||||
@@ -6,14 +6,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class AlbumInfoCard extends HookConsumerWidget {
|
||||
final Uint8List? imageData;
|
||||
final AssetPathEntity albumInfo;
|
||||
final AvailableAlbum albumInfo;
|
||||
|
||||
const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo})
|
||||
: super(key: key);
|
||||
@@ -24,6 +24,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
|
||||
final bool isExcluded =
|
||||
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
ColorFilter selectedFilter = ColorFilter.mode(
|
||||
Theme.of(context).primaryColor.withAlpha(100),
|
||||
@@ -39,11 +40,11 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
return Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
label: const Text(
|
||||
label: Text(
|
||||
"album_info_card_backup_album_included",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
color: isDarkTheme ? Colors.black : Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
@@ -53,11 +54,11 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
return Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
label: const Text(
|
||||
label: Text(
|
||||
"album_info_card_backup_album_excluded",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
color: isDarkTheme ? Colors.black : Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
@@ -122,7 +123,7 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if (albumInfo.id == 'isAll') {
|
||||
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Cannot exclude album contains all assets',
|
||||
@@ -141,8 +142,10 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
margin: const EdgeInsets.all(1),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12), // if you need this
|
||||
side: const BorderSide(
|
||||
color: Color(0xFFC9C9C9),
|
||||
side: BorderSide(
|
||||
color: isDarkTheme
|
||||
? const Color.fromARGB(255, 37, 35, 35)
|
||||
: const Color(0xFFC9C9C9),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -219,8 +222,9 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
AutoRouter.of(context)
|
||||
.push(AlbumPreviewRoute(album: albumInfo));
|
||||
AutoRouter.of(context).push(
|
||||
AlbumPreviewRoute(album: albumInfo.albumEntity),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.image_outlined,
|
||||
|
||||
@@ -35,7 +35,7 @@ class BackupInfoCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
subtitle,
|
||||
style: const TextStyle(color: Color(0xFF808080), fontSize: 12),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
trailing: Column(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
@@ -16,6 +17,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
final availableAlbums = ref.watch(backupProvider).availableAlbums;
|
||||
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
|
||||
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -46,7 +48,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
: const EdgeInsets.all(0),
|
||||
child: AlbumInfoCard(
|
||||
imageData: thumbnailData,
|
||||
albumInfo: availableAlbums[index].albumEntity,
|
||||
albumInfo: availableAlbums[index],
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -81,14 +83,16 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
),
|
||||
label: Text(
|
||||
album.name,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
deleteIconColor: Colors.white,
|
||||
deleteIconColor: isDarkTheme ? Colors.black : Colors.white,
|
||||
deleteIcon: const Icon(
|
||||
Icons.cancel_rounded,
|
||||
size: 15,
|
||||
@@ -119,14 +123,15 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
),
|
||||
label: Text(
|
||||
album.name,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
color: isDarkTheme ? Colors.black : immichBackgroundColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
backgroundColor: Colors.red[300],
|
||||
deleteIconColor: Colors.white,
|
||||
deleteIconColor:
|
||||
isDarkTheme ? Colors.black : immichBackgroundColor,
|
||||
deleteIcon: const Icon(
|
||||
Icons.cancel_rounded,
|
||||
size: 15,
|
||||
@@ -154,11 +159,16 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
horizontal: 16.0,
|
||||
),
|
||||
child: const Text(
|
||||
"backup_album_selection_page_selection_info",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
// Selected Album Chips
|
||||
@@ -178,9 +188,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
child: Card(
|
||||
margin: const EdgeInsets.all(0),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5), // if you need this
|
||||
side: const BorderSide(
|
||||
color: Color.fromARGB(255, 235, 235, 235),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
side: BorderSide(
|
||||
color: isDarkTheme
|
||||
? const Color.fromARGB(255, 0, 0, 0)
|
||||
: const Color.fromARGB(255, 235, 235, 235),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -190,12 +202,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
children: [
|
||||
ListTile(
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: Text(
|
||||
title: const Text(
|
||||
"backup_album_selection_page_total_assets",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
).tr(),
|
||||
trailing: Text(
|
||||
@@ -257,11 +268,10 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
|
||||
content: SingleChildScrollView(
|
||||
child: ListBody(
|
||||
children: [
|
||||
Text(
|
||||
const Text(
|
||||
'backup_album_selection_page_assets_scatter',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
).tr(),
|
||||
],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -20,9 +22,12 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
BackUpState backupState = ref.watch(backupProvider);
|
||||
AuthenticationState authenticationState = ref.watch(authenticationProvider);
|
||||
bool hasExclusiveAccess =
|
||||
backupState.backupProgress != BackUpProgressEnum.inBackground;
|
||||
bool shouldBackup = backupState.allUniqueAssets.length -
|
||||
backupState.selectedAlbumsBackupAssetsIds.length ==
|
||||
0
|
||||
backupState.selectedAlbumsBackupAssetsIds.length ==
|
||||
0 ||
|
||||
!hasExclusiveAccess
|
||||
? false
|
||||
: true;
|
||||
|
||||
@@ -82,7 +87,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
ListTile _buildBackupController() {
|
||||
ListTile _buildAutoBackupController() {
|
||||
var backUpOption = authenticationState.deviceInfo.isAutoBackup
|
||||
? "backup_controller_page_status_on".tr()
|
||||
: "backup_controller_page_status_off".tr();
|
||||
@@ -114,13 +119,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
).tr(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(
|
||||
width: 1,
|
||||
color: Color.fromARGB(255, 220, 220, 220),
|
||||
),
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
if (isAutoBackup) {
|
||||
ref
|
||||
@@ -134,7 +133,10 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
},
|
||||
child: Text(
|
||||
backupBtnText,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -144,6 +146,99 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showErrorToUser(String msg) {
|
||||
final snackBar = SnackBar(
|
||||
content: Text(
|
||||
msg.tr(),
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
}
|
||||
|
||||
ListTile _buildBackgroundBackupController() {
|
||||
final bool isBackgroundEnabled = backupState.backgroundBackup;
|
||||
final bool isWifiRequired = backupState.backupRequireWifi;
|
||||
final bool isChargingRequired = backupState.backupRequireCharging;
|
||||
final Color activeColor = Theme.of(context).primaryColor;
|
||||
return ListTile(
|
||||
isThreeLine: true,
|
||||
leading: isBackgroundEnabled
|
||||
? Icon(
|
||||
Icons.cloud_sync_rounded,
|
||||
color: activeColor,
|
||||
)
|
||||
: const Icon(Icons.cloud_sync_rounded),
|
||||
title: Text(
|
||||
isBackgroundEnabled
|
||||
? "backup_controller_page_background_is_on"
|
||||
: "backup_controller_page_background_is_off",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
).tr(),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isBackgroundEnabled)
|
||||
const Text("backup_controller_page_background_description").tr(),
|
||||
if (isBackgroundEnabled)
|
||||
SwitchListTile(
|
||||
title:
|
||||
const Text("backup_controller_page_background_wifi").tr(),
|
||||
secondary: Icon(
|
||||
Icons.wifi,
|
||||
color: isWifiRequired ? activeColor : null,
|
||||
),
|
||||
dense: true,
|
||||
activeColor: activeColor,
|
||||
value: isWifiRequired,
|
||||
onChanged: hasExclusiveAccess
|
||||
? (isChecked) => ref
|
||||
.read(backupProvider.notifier)
|
||||
.configureBackgroundBackup(
|
||||
requireWifi: isChecked,
|
||||
onError: _showErrorToUser,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
if (isBackgroundEnabled)
|
||||
SwitchListTile(
|
||||
title: const Text("backup_controller_page_background_charging")
|
||||
.tr(),
|
||||
secondary: Icon(
|
||||
Icons.charging_station,
|
||||
color: isChargingRequired ? activeColor : null,
|
||||
),
|
||||
dense: true,
|
||||
activeColor: activeColor,
|
||||
value: isChargingRequired,
|
||||
onChanged: hasExclusiveAccess
|
||||
? (isChecked) => ref
|
||||
.read(backupProvider.notifier)
|
||||
.configureBackgroundBackup(
|
||||
requireCharging: isChecked,
|
||||
onError: _showErrorToUser,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
ref.read(backupProvider.notifier).configureBackgroundBackup(
|
||||
enabled: !isBackgroundEnabled,
|
||||
onError: _showErrorToUser,
|
||||
),
|
||||
child: Text(
|
||||
isBackgroundEnabled
|
||||
? "backup_controller_page_background_turn_off"
|
||||
: "backup_controller_page_background_turn_on",
|
||||
style:
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectedAlbumName() {
|
||||
var text = "backup_controller_page_backup_selected".tr();
|
||||
var albums = ref.watch(backupProvider).selectedBackupAlbums;
|
||||
@@ -232,33 +327,27 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
children: [
|
||||
const Text(
|
||||
"backup_controller_page_to_backup",
|
||||
style: TextStyle(color: Color(0xFF808080), fontSize: 12),
|
||||
style: TextStyle(fontSize: 12),
|
||||
).tr(),
|
||||
_buildSelectedAlbumName(),
|
||||
_buildExcludedAlbumName()
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
enableFeedback: true,
|
||||
side: const BorderSide(
|
||||
width: 1,
|
||||
color: Color.fromARGB(255, 220, 220, 220),
|
||||
trailing: ElevatedButton(
|
||||
onPressed: hasExclusiveAccess
|
||||
? () {
|
||||
AutoRouter.of(context)
|
||||
.push(const BackupAlbumSelectionRoute());
|
||||
}
|
||||
: null,
|
||||
child: const Text(
|
||||
"backup_controller_page_select",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16.0,
|
||||
),
|
||||
child: const Text(
|
||||
"backup_controller_page_select",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -324,14 +413,14 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Table(
|
||||
border: TableBorder.all(
|
||||
color: Colors.black12,
|
||||
color: Theme.of(context).primaryColorLight,
|
||||
width: 1,
|
||||
),
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
// color: Colors.grey[100],
|
||||
),
|
||||
children: [
|
||||
TableCell(
|
||||
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||
@@ -355,9 +444,9 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
// color: Colors.grey[200],
|
||||
),
|
||||
children: [
|
||||
TableCell(
|
||||
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||
@@ -384,9 +473,9 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
// color: Colors.grey[100],
|
||||
),
|
||||
children: [
|
||||
TableCell(
|
||||
child: Padding(
|
||||
@@ -412,7 +501,10 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
|
||||
void startBackup() {
|
||||
ref.watch(errorBackupListProvider.notifier).empty();
|
||||
ref.watch(backupProvider.notifier).startBackupProcess();
|
||||
if (ref.watch(backupProvider).backupProgress !=
|
||||
BackUpProgressEnum.inBackground) {
|
||||
ref.watch(backupProvider.notifier).startBackupProcess();
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
@@ -445,6 +537,27 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
).tr(),
|
||||
),
|
||||
hasExclusiveAccess
|
||||
? const SizedBox.shrink()
|
||||
: Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(5), // if you need this
|
||||
side: const BorderSide(
|
||||
color: Colors.black12,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
elevation: 0,
|
||||
borderOnForeground: false,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
"Background backup is currently running, some actions are disabled",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildFolderSelectionTile(),
|
||||
BackupInfoCard(
|
||||
title: "backup_controller_page_total".tr(),
|
||||
@@ -463,7 +576,9 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
"${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
|
||||
),
|
||||
const Divider(),
|
||||
_buildBackupController(),
|
||||
_buildAutoBackupController(),
|
||||
if (Platform.isAndroid) const Divider(),
|
||||
if (Platform.isAndroid) _buildBackgroundBackupController(),
|
||||
const Divider(),
|
||||
_buildStorageInformation(),
|
||||
const Divider(),
|
||||
@@ -479,7 +594,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Colors.red[300],
|
||||
onPrimary: Colors.grey[50],
|
||||
padding: const EdgeInsets.all(14),
|
||||
// padding: const EdgeInsets.all(14),
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(backupProvider.notifier).cancelBackup();
|
||||
@@ -493,11 +608,6 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
).tr(),
|
||||
)
|
||||
: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Theme.of(context).primaryColor,
|
||||
onPrimary: Colors.grey[50],
|
||||
padding: const EdgeInsets.all(14),
|
||||
),
|
||||
onPressed: shouldBackup ? startBackup : null,
|
||||
child: const Text(
|
||||
"backup_controller_page_start_backup",
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
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/modules/home/ui/delete_diaglog.dart';
|
||||
|
||||
import '../../../shared/providers/asset.provider.dart';
|
||||
import '../providers/home_page_state.provider.dart';
|
||||
|
||||
class ControlBottomAppBar extends ConsumerWidget {
|
||||
const ControlBottomAppBar({Key? key}) : super(key: key);
|
||||
|
||||
@@ -19,10 +17,10 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
height: MediaQuery.of(context).size.height * 0.15,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(15),
|
||||
topRight: Radius.circular(15),
|
||||
topLeft: Radius.circular(8),
|
||||
topRight: Radius.circular(8),
|
||||
),
|
||||
color: Colors.grey[300]?.withOpacity(0.98),
|
||||
color: Theme.of(context).scaffoldBackgroundColor.withOpacity(0.95),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
@@ -86,7 +86,6 @@ class DailyTitleText extends ConsumerWidget {
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
@@ -14,32 +14,22 @@ class DisableMultiSelectButton extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
top: 10,
|
||||
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',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
onPressed();
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: Text(
|
||||
'$selectedItemCount',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -30,6 +30,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
floating: true,
|
||||
pinned: false,
|
||||
snap: false,
|
||||
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(5)),
|
||||
),
|
||||
@@ -57,7 +58,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
child: GestureDetector(
|
||||
onTap: () => Scaffold.of(context).openDrawer(),
|
||||
child: Material(
|
||||
color: Colors.grey[200],
|
||||
// color: Colors.grey[200],
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50.0),
|
||||
@@ -77,13 +78,12 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
title: const Text(
|
||||
'IMMICH',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SnowburstOne',
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 22,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
@@ -112,12 +112,13 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
? const Icon(Icons.backup_rounded)
|
||||
: Badge(
|
||||
padding: const EdgeInsets.all(4),
|
||||
elevation: 2,
|
||||
elevation: 3,
|
||||
position: BadgePosition.bottomEnd(bottom: -4, end: -4),
|
||||
badgeColor: Colors.white,
|
||||
badgeContent: const Icon(
|
||||
Icons.cloud_off_rounded,
|
||||
size: 8,
|
||||
color: Colors.indigo,
|
||||
),
|
||||
child: const Icon(Icons.backup_rounded),
|
||||
),
|
||||
|
||||
@@ -22,7 +22,7 @@ class MonthlyTitleText extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
color: Theme.of(context).textTheme.headline1?.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/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/shared/models/server_info_state.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'dart:math';
|
||||
|
||||
class ProfileDrawer extends HookConsumerWidget {
|
||||
const ProfileDrawer({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
AuthenticationState authState = ref.watch(authenticationProvider);
|
||||
ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
|
||||
final uploadProfileImageStatus =
|
||||
ref.watch(uploadProfileImageProvider).status;
|
||||
final appInfo = useState({});
|
||||
var dummmy = Random().nextInt(1024);
|
||||
|
||||
_getPackageInfo() async {
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
|
||||
appInfo.value = {
|
||||
"version": packageInfo.version,
|
||||
"buildNumber": packageInfo.buildNumber,
|
||||
};
|
||||
}
|
||||
|
||||
_buildUserProfileImage() {
|
||||
if (authState.profileImagePath.isEmpty) {
|
||||
return const CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
if (uploadProfileImageStatus == UploadProfileStatus.idle) {
|
||||
if (authState.profileImagePath.isNotEmpty) {
|
||||
return CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: NetworkImage(
|
||||
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
} else {
|
||||
return const CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadProfileImageStatus == UploadProfileStatus.success) {
|
||||
return CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: NetworkImage(
|
||||
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
if (uploadProfileImageStatus == UploadProfileStatus.failure) {
|
||||
return const CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
|
||||
return const ImmichLoadingIndicator();
|
||||
}
|
||||
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
_pickUserProfileImage() async {
|
||||
final XFile? image = await ImagePicker().pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxHeight: 1024,
|
||||
maxWidth: 1024,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
var success =
|
||||
await ref.watch(uploadProfileImageProvider.notifier).upload(image);
|
||||
|
||||
if (success) {
|
||||
ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
|
||||
ref.read(uploadProfileImageProvider).profileImagePath,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
_getPackageInfo();
|
||||
_buildUserProfileImage();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
return Drawer(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ListView(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
DrawerHeader(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Color.fromARGB(255, 216, 219, 238),
|
||||
Color.fromARGB(255, 226, 230, 231)
|
||||
],
|
||||
begin: Alignment.centerRight,
|
||||
end: Alignment.centerLeft,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
_buildUserProfileImage(),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: -5,
|
||||
child: GestureDetector(
|
||||
onTap: _pickUserProfileImage,
|
||||
child: Material(
|
||||
color: Colors.grey[50],
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50.0),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5.0),
|
||||
child: Icon(
|
||||
Icons.edit,
|
||||
color: Theme.of(context).primaryColor,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
"${authState.firstName} ${authState.lastName}",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 24,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
authState.userEmail,
|
||||
style: TextStyle(color: Colors.grey[800], fontSize: 12),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
tileColor: Colors.grey[100],
|
||||
leading: const Icon(
|
||||
Icons.logout_rounded,
|
||||
color: Colors.black54,
|
||||
),
|
||||
title: const Text(
|
||||
"profile_drawer_sign_out",
|
||||
style: TextStyle(
|
||||
color: Colors.black54,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
onTap: () async {
|
||||
bool res =
|
||||
await ref.watch(authenticationProvider.notifier).logout();
|
||||
|
||||
if (res) {
|
||||
ref.watch(backupProvider.notifier).cancelBackup();
|
||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||
ref.watch(websocketProvider.notifier).disconnect();
|
||||
// AutoRouter.of(context).popUntilRoot();
|
||||
AutoRouter.of(context).replace(const LoginRoute());
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: Colors.grey[100],
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5), // if you need this
|
||||
side: const BorderSide(
|
||||
color: Color.fromARGB(101, 201, 201, 201),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
serverInfoState.isVersionMismatch
|
||||
? serverInfoState.versionMismatchErrorMessage
|
||||
: "profile_drawer_client_server_up_to_date".tr(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"App Version",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"Server Version",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer_header.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
|
||||
class ProfileDrawer extends HookConsumerWidget {
|
||||
const ProfileDrawer({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
_buildSignoutButton() {
|
||||
return ListTile(
|
||||
horizontalTitleGap: 0,
|
||||
leading: SizedBox(
|
||||
height: double.infinity,
|
||||
child: Icon(
|
||||
Icons.logout_rounded,
|
||||
color: Theme.of(context).textTheme.labelMedium?.color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
"profile_drawer_sign_out",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () async {
|
||||
bool res = await ref.watch(authenticationProvider.notifier).logout();
|
||||
|
||||
if (res) {
|
||||
ref.watch(backupProvider.notifier).cancelBackup();
|
||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||
ref.watch(websocketProvider.notifier).disconnect();
|
||||
AutoRouter.of(context).replace(const LoginRoute());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_buildSettingButton() {
|
||||
return ListTile(
|
||||
horizontalTitleGap: 0,
|
||||
leading: SizedBox(
|
||||
height: double.infinity,
|
||||
child: Icon(
|
||||
Icons.settings_rounded,
|
||||
color: Theme.of(context).textTheme.labelMedium?.color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
"profile_drawer_settings",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(const SettingsRoute());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Drawer(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ListView(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
const ProfileDrawerHeader(),
|
||||
_buildSettingButton(),
|
||||
_buildSignoutButton(),
|
||||
],
|
||||
),
|
||||
const ServerInfoBox()
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/upload_profile_image.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/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
class ProfileDrawerHeader extends HookConsumerWidget {
|
||||
const ProfileDrawerHeader({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
AuthenticationState authState = ref.watch(authenticationProvider);
|
||||
final uploadProfileImageStatus =
|
||||
ref.watch(uploadProfileImageProvider).status;
|
||||
var dummmy = Random().nextInt(1024);
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
_buildUserProfileImage() {
|
||||
if (authState.profileImagePath.isEmpty) {
|
||||
return const CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
if (uploadProfileImageStatus == UploadProfileStatus.idle) {
|
||||
if (authState.profileImagePath.isNotEmpty) {
|
||||
return CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: NetworkImage(
|
||||
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
} else {
|
||||
return const CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadProfileImageStatus == UploadProfileStatus.success) {
|
||||
return CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: NetworkImage(
|
||||
'$endpoint/user/profile-image/${authState.userId}?d=${dummmy++}',
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
if (uploadProfileImageStatus == UploadProfileStatus.failure) {
|
||||
return const CircleAvatar(
|
||||
radius: 35,
|
||||
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
|
||||
return const ImmichLoadingIndicator();
|
||||
}
|
||||
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
_pickUserProfileImage() async {
|
||||
final XFile? image = await ImagePicker().pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxHeight: 1024,
|
||||
maxWidth: 1024,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
var success =
|
||||
await ref.watch(uploadProfileImageProvider.notifier).upload(image);
|
||||
|
||||
if (success) {
|
||||
ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
|
||||
ref.read(uploadProfileImageProvider).profileImagePath,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
_buildUserProfileImage();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return DrawerHeader(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: isDarkMode
|
||||
? [
|
||||
const Color.fromARGB(255, 22, 25, 48),
|
||||
const Color.fromARGB(255, 13, 13, 13),
|
||||
const Color.fromARGB(255, 0, 0, 0),
|
||||
]
|
||||
: [
|
||||
const Color.fromARGB(255, 216, 219, 238),
|
||||
const Color.fromARGB(255, 242, 242, 242),
|
||||
Colors.white,
|
||||
],
|
||||
begin: Alignment.centerRight,
|
||||
end: Alignment.centerLeft,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
_buildUserProfileImage(),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: -5,
|
||||
child: GestureDetector(
|
||||
onTap: _pickUserProfileImage,
|
||||
child: Material(
|
||||
color: Colors.grey[100],
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50.0),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5.0),
|
||||
child: Icon(
|
||||
Icons.edit,
|
||||
color: Theme.of(context).primaryColor,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
"${authState.firstName} ${authState.lastName}",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 24,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
authState.userEmail,
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
mobile/lib/modules/home/ui/profile_drawer/server_info_box.dart
Normal file
124
mobile/lib/modules/home/ui/profile_drawer/server_info_box.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class ServerInfoBox extends HookConsumerWidget {
|
||||
const ServerInfoBox({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
|
||||
|
||||
final appInfo = useState({});
|
||||
|
||||
_getPackageInfo() async {
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
|
||||
appInfo.value = {
|
||||
"version": packageInfo.version,
|
||||
"buildNumber": packageInfo.buildNumber,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
_getPackageInfo();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5), // if you need this
|
||||
side: const BorderSide(
|
||||
color: Color.fromARGB(101, 201, 201, 201),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
serverInfoState.isVersionMismatch
|
||||
? serverInfoState.versionMismatchErrorMessage
|
||||
: "profile_drawer_client_server_up_to_date".tr(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(
|
||||
color: Color.fromARGB(101, 201, 201, 201),
|
||||
thickness: 1,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"App Version",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(
|
||||
color: Color.fromARGB(101, 201, 201, 201),
|
||||
thickness: 1,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"Server Version",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ 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/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/profile_drawer.dart';
|
||||
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
@@ -117,9 +117,9 @@ class HomePage extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 50.0),
|
||||
padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
|
||||
child: DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
|
||||
@@ -26,7 +26,7 @@ class ThumbnailWithInfo extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 2,
|
||||
width: MediaQuery.of(context).size.width / 3,
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
@@ -58,7 +58,7 @@ class ThumbnailWithInfo extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -29,6 +29,8 @@ class SearchPage extends HookConsumerWidget {
|
||||
AsyncValue<List<CuratedObjectsResponseDto>> curatedObjects =
|
||||
ref.watch(getCuratedObjectProvider);
|
||||
|
||||
double imageSize = MediaQuery.of(context).size.width / 3;
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
searchFocusNode = FocusNode();
|
||||
@@ -46,15 +48,15 @@ class SearchPage extends HookConsumerWidget {
|
||||
|
||||
_buildPlaces() {
|
||||
return curatedLocation.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: ImmichLoadingIndicator()),
|
||||
loading: () => SizedBox(
|
||||
height: imageSize,
|
||||
child: const Center(child: ImmichLoadingIndicator()),
|
||||
),
|
||||
error: (err, stack) => Text('Error: $err'),
|
||||
data: (curatedLocations) {
|
||||
return curatedLocations.isNotEmpty
|
||||
? SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 2,
|
||||
height: imageSize,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
@@ -76,7 +78,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 2,
|
||||
height: imageSize,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
@@ -105,7 +107,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
data: (objects) {
|
||||
return objects.isNotEmpty
|
||||
? SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 2,
|
||||
height: imageSize,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
@@ -113,7 +115,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
itemBuilder: ((context, index) {
|
||||
var curatedObjectInfo = objects[index];
|
||||
var thumbnailRequestUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/file?aid=${curatedObjectInfo.deviceAssetId}&did=${curatedObjectInfo.deviceId}&isThumb=true';
|
||||
'${box.get(serverEndpointKey)}/asset/thumbnail/${curatedObjectInfo.id}';
|
||||
|
||||
return ThumbnailWithInfo(
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
@@ -131,7 +133,8 @@ class SearchPage extends HookConsumerWidget {
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 2,
|
||||
// height: imageSize,
|
||||
width: imageSize,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
@@ -163,12 +166,13 @@ class SearchPage extends HookConsumerWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: const Text(
|
||||
"search_page_places",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
).tr(),
|
||||
),
|
||||
_buildPlaces(),
|
||||
@@ -176,7 +180,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: const Text(
|
||||
"search_page_things",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
).tr(),
|
||||
),
|
||||
_buildThings()
|
||||
|
||||
@@ -172,7 +172,7 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
});
|
||||
|
||||
return DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
|
||||
final appSettingsServiceProvider = Provider((ref) => AppSettingsService());
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
|
||||
enum AppSettingsEnum {
|
||||
threeStageLoading, // true, false,
|
||||
themeMode, // "light","dark","system"
|
||||
}
|
||||
|
||||
class AppSettingsService {
|
||||
late final Box hiveBox;
|
||||
|
||||
AppSettingsService() {
|
||||
hiveBox = Hive.box(userSettingInfoBox);
|
||||
}
|
||||
|
||||
T getSetting<T>(AppSettingsEnum settingType) {
|
||||
var settingKey = _settingHiveBoxKeyLookup(settingType);
|
||||
|
||||
if (!hiveBox.containsKey(settingKey)) {
|
||||
T defaultSetting = _setDefaultSetting(settingType);
|
||||
return defaultSetting;
|
||||
}
|
||||
|
||||
var result = hiveBox.get(settingKey);
|
||||
|
||||
if (result is T) {
|
||||
return result;
|
||||
} else {
|
||||
debugPrint("Incorrect setting type");
|
||||
throw TypeError();
|
||||
}
|
||||
}
|
||||
|
||||
setSetting<T>(AppSettingsEnum settingType, T value) {
|
||||
var settingKey = _settingHiveBoxKeyLookup(settingType);
|
||||
|
||||
if (hiveBox.containsKey(settingKey)) {
|
||||
var result = hiveBox.get(settingKey);
|
||||
|
||||
if (result is! T) {
|
||||
debugPrint("Incorrect setting type");
|
||||
throw TypeError();
|
||||
}
|
||||
|
||||
hiveBox.put(settingKey, value);
|
||||
} else {
|
||||
hiveBox.put(settingKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
_setDefaultSetting(AppSettingsEnum settingType) {
|
||||
var settingKey = _settingHiveBoxKeyLookup(settingType);
|
||||
|
||||
// Default value of threeStageLoading is false
|
||||
if (settingType == AppSettingsEnum.threeStageLoading) {
|
||||
hiveBox.put(settingKey, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default value of themeMode is "light"
|
||||
if (settingType == AppSettingsEnum.themeMode) {
|
||||
hiveBox.put(settingKey, "system");
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
String _settingHiveBoxKeyLookup(AppSettingsEnum settingType) {
|
||||
switch (settingType) {
|
||||
case AppSettingsEnum.threeStageLoading:
|
||||
return 'threeStageLoading';
|
||||
case AppSettingsEnum.themeMode:
|
||||
return 'themeMode';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/three_stage_loading.dart';
|
||||
|
||||
class ImageViewerQualitySetting extends StatelessWidget {
|
||||
const ImageViewerQualitySetting({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ExpansionTile(
|
||||
textColor: Theme.of(context).primaryColor,
|
||||
title: const Text(
|
||||
'theme_setting_image_viewer_quality_title',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
subtitle: const Text(
|
||||
'theme_setting_image_viewer_quality_subtitle',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
),
|
||||
).tr(),
|
||||
children: const [
|
||||
ThreeStageLoading(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
|
||||
class ThreeStageLoading extends HookConsumerWidget {
|
||||
const ThreeStageLoading({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final isEnable = useState(false);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
var isThreeStageLoadingEnable =
|
||||
appSettingService.getSetting(AppSettingsEnum.threeStageLoading);
|
||||
|
||||
isEnable.value = isThreeStageLoadingEnable;
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
void onSwitchChanged(bool switchValue) {
|
||||
appSettingService.setSetting(
|
||||
AppSettingsEnum.threeStageLoading,
|
||||
switchValue,
|
||||
);
|
||||
isEnable.value = switchValue;
|
||||
}
|
||||
|
||||
return SwitchListTile.adaptive(
|
||||
title: const Text(
|
||||
"theme_setting_three_stage_loading_title",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
subtitle: const Text(
|
||||
"theme_setting_three_stage_loading_subtitle",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr(),
|
||||
value: isEnable.value,
|
||||
onChanged: onSwitchChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
106
mobile/lib/modules/settings/ui/theme_setting/theme_setting.dart
Normal file
106
mobile/lib/modules/settings/ui/theme_setting/theme_setting.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
||||
|
||||
class ThemeSetting extends HookConsumerWidget {
|
||||
const ThemeSetting({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentTheme = useState<ThemeMode>(ThemeMode.system);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
currentTheme.value = ref.read(immichThemeProvider);
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return ExpansionTile(
|
||||
textColor: Theme.of(context).primaryColor,
|
||||
title: const Text(
|
||||
'theme_setting_theme_title',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
subtitle: const Text(
|
||||
'theme_setting_theme_subtitle',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
),
|
||||
).tr(),
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
title: const Text(
|
||||
'theme_setting_system_theme_switch',
|
||||
style: TextStyle(
|
||||
fontSize: 12.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
value: currentTheme.value == ThemeMode.system,
|
||||
onChanged: (bool isSystem) {
|
||||
var currentSystemBrightness =
|
||||
MediaQuery.of(context).platformBrightness;
|
||||
|
||||
if (isSystem) {
|
||||
currentTheme.value = ThemeMode.system;
|
||||
ref.watch(immichThemeProvider.notifier).state = ThemeMode.system;
|
||||
ref
|
||||
.watch(appSettingsServiceProvider)
|
||||
.setSetting(AppSettingsEnum.themeMode, "system");
|
||||
} else {
|
||||
if (currentSystemBrightness == Brightness.light) {
|
||||
currentTheme.value = ThemeMode.light;
|
||||
ref.watch(immichThemeProvider.notifier).state = ThemeMode.light;
|
||||
ref
|
||||
.watch(appSettingsServiceProvider)
|
||||
.setSetting(AppSettingsEnum.themeMode, "light");
|
||||
} else if (currentSystemBrightness == Brightness.dark) {
|
||||
currentTheme.value = ThemeMode.dark;
|
||||
ref.watch(immichThemeProvider.notifier).state = ThemeMode.dark;
|
||||
ref
|
||||
.watch(appSettingsServiceProvider)
|
||||
.setSetting(AppSettingsEnum.themeMode, "dark");
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
if (currentTheme.value != ThemeMode.system)
|
||||
SwitchListTile.adaptive(
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
title: const Text(
|
||||
'theme_setting_dark_mode_switch',
|
||||
style: TextStyle(
|
||||
fontSize: 12.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
value: ref.watch(immichThemeProvider) == ThemeMode.dark,
|
||||
onChanged: (bool isDark) {
|
||||
if (isDark) {
|
||||
ref.watch(immichThemeProvider.notifier).state = ThemeMode.dark;
|
||||
ref
|
||||
.watch(appSettingsServiceProvider)
|
||||
.setSetting(AppSettingsEnum.themeMode, "dark");
|
||||
} else {
|
||||
ref.watch(immichThemeProvider.notifier).state = ThemeMode.light;
|
||||
ref
|
||||
.watch(appSettingsServiceProvider)
|
||||
.setSetting(AppSettingsEnum.themeMode, "light");
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
45
mobile/lib/modules/settings/views/settings_page.dart
Normal file
45
mobile/lib/modules/settings/views/settings_page.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
|
||||
|
||||
class SettingsPage extends HookConsumerWidget {
|
||||
const SettingsPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
iconSize: 20,
|
||||
splashRadius: 24,
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
),
|
||||
automaticallyImplyLeading: false,
|
||||
centerTitle: false,
|
||||
title: const Text(
|
||||
'setting_pages_app_bar_settings',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
...ListTile.divideTiles(
|
||||
context: context,
|
||||
tiles: [
|
||||
const ImageViewerQualitySetting(),
|
||||
const ThemeSetting(),
|
||||
],
|
||||
).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/album/views/create_album_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/sharing_page.dart';
|
||||
import 'package:immich_mobile/modules/settings/views/settings_page.dart';
|
||||
import 'package:immich_mobile/routing/auth_guard.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
||||
@@ -77,6 +78,7 @@ part 'router.gr.dart';
|
||||
guards: [AuthGuard],
|
||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||
),
|
||||
AutoRoute(page: SettingsPage, guards: [AuthGuard]),
|
||||
],
|
||||
)
|
||||
class AppRouter extends _$AppRouter {
|
||||
|
||||
@@ -137,6 +137,10 @@ class _$AppRouter extends RootStackRouter {
|
||||
opaque: true,
|
||||
barrierDismissible: false);
|
||||
},
|
||||
SettingsRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData, child: const SettingsPage());
|
||||
},
|
||||
HomeRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData, child: const HomePage());
|
||||
@@ -211,7 +215,9 @@ class _$AppRouter extends RootStackRouter {
|
||||
RouteConfig(AlbumPreviewRoute.name,
|
||||
path: '/album-preview-page', guards: [authGuard]),
|
||||
RouteConfig(FailedBackupStatusRoute.name,
|
||||
path: '/failed-backup-status-page', guards: [authGuard])
|
||||
path: '/failed-backup-status-page', guards: [authGuard]),
|
||||
RouteConfig(SettingsRoute.name,
|
||||
path: '/settings-page', guards: [authGuard])
|
||||
];
|
||||
}
|
||||
|
||||
@@ -546,6 +552,14 @@ class FailedBackupStatusRoute extends PageRouteInfo<void> {
|
||||
static const String name = 'FailedBackupStatusRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [SettingsPage]
|
||||
class SettingsRoute extends PageRouteInfo<void> {
|
||||
const SettingsRoute() : super(SettingsRoute.name, path: '/settings-page');
|
||||
|
||||
static const String name = 'SettingsRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [HomePage]
|
||||
class HomeRoute extends PageRouteInfo<void> {
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.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/immich_colors.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
@@ -49,7 +48,6 @@ class SplashScreenPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: immichBackgroundColor,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
@@ -36,8 +35,6 @@ class TabControllerPage extends ConsumerWidget {
|
||||
bottomNavigationBar: isMultiSelectEnable
|
||||
? null
|
||||
: BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: immichBackgroundColor,
|
||||
selectedLabelStyle: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -53,21 +50,23 @@ class TabControllerPage extends ConsumerWidget {
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
label: 'tab_controller_nav_photos'.tr(),
|
||||
icon: const Icon(Icons.photo),
|
||||
icon: const Icon(Icons.photo_outlined),
|
||||
activeIcon: const Icon(Icons.photo),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: 'tab_controller_nav_search'.tr(),
|
||||
icon: const Icon(Icons.search),
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
activeIcon: const Icon(Icons.search),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: 'tab_controller_nav_sharing'.tr(),
|
||||
icon: const Icon(Icons.group_outlined),
|
||||
activeIcon: const Icon(Icons.group),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: 'tab_controller_nav_library'.tr(),
|
||||
icon: const Icon(
|
||||
Icons.photo_album_outlined,
|
||||
),
|
||||
icon: const Icon(Icons.photo_album_outlined),
|
||||
activeIcon: const Icon(Icons.photo_album_rounded),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
133
mobile/lib/utils/immich_app_theme.dart
Normal file
133
mobile/lib/utils/immich_app_theme.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
|
||||
final immichThemeProvider = StateProvider<ThemeMode>((ref) {
|
||||
var themeMode = ref
|
||||
.watch(appSettingsServiceProvider)
|
||||
.getSetting(AppSettingsEnum.themeMode);
|
||||
|
||||
debugPrint("Current themeMode $themeMode");
|
||||
|
||||
if (themeMode == "light") {
|
||||
return ThemeMode.light;
|
||||
} else if (themeMode == "dark") {
|
||||
return ThemeMode.dark;
|
||||
} else {
|
||||
return ThemeMode.system;
|
||||
}
|
||||
});
|
||||
|
||||
ThemeData immichDarkTheme = ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Colors.indigo,
|
||||
primaryColor: immichDarkThemePrimaryColor,
|
||||
scaffoldBackgroundColor: immichDarkBackgroundColor,
|
||||
hintColor: Colors.grey[600],
|
||||
fontFamily: 'WorkSans',
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'WorkSans',
|
||||
color: immichDarkThemePrimaryColor,
|
||||
),
|
||||
backgroundColor: const Color.fromARGB(255, 32, 33, 35),
|
||||
foregroundColor: immichDarkThemePrimaryColor,
|
||||
elevation: 1,
|
||||
centerTitle: true,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
),
|
||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: const Color.fromARGB(255, 35, 36, 37),
|
||||
selectedItemColor: immichDarkThemePrimaryColor,
|
||||
),
|
||||
drawerTheme: DrawerThemeData(
|
||||
backgroundColor: immichDarkBackgroundColor,
|
||||
scrimColor: Colors.white.withOpacity(0.1),
|
||||
),
|
||||
textTheme: TextTheme(
|
||||
headline1: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color.fromARGB(255, 255, 255, 255),
|
||||
),
|
||||
headline2: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color.fromARGB(255, 148, 151, 155),
|
||||
),
|
||||
headline3: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: immichDarkThemePrimaryColor,
|
||||
),
|
||||
),
|
||||
cardColor: Colors.grey[900],
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
onPrimary: Colors.black87,
|
||||
primary: immichDarkThemePrimaryColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
ThemeData immichLightTheme = ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
primarySwatch: Colors.indigo,
|
||||
hintColor: Colors.indigo,
|
||||
fontFamily: 'WorkSans',
|
||||
scaffoldBackgroundColor: immichBackgroundColor,
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
titleTextStyle: const TextStyle(
|
||||
fontFamily: 'WorkSans',
|
||||
color: Colors.indigo,
|
||||
),
|
||||
backgroundColor: immichBackgroundColor,
|
||||
foregroundColor: Colors.indigo,
|
||||
elevation: 1,
|
||||
centerTitle: true,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
||||
),
|
||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: immichBackgroundColor,
|
||||
selectedItemColor: Colors.indigo,
|
||||
),
|
||||
drawerTheme: DrawerThemeData(
|
||||
backgroundColor: immichBackgroundColor,
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
headline1: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.indigo,
|
||||
),
|
||||
headline2: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
headline3: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.indigo,
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Colors.indigo,
|
||||
onPrimary: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.22.0+32
|
||||
version: 1.24.0+34
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
|
||||
@@ -25,7 +25,7 @@ export class AuthController {
|
||||
|
||||
@Post('/login')
|
||||
async login(
|
||||
@Body(ValidationPipe) loginCredential: LoginCredentialDto,
|
||||
@Body(new ValidationPipe({ transform: true })) loginCredential: LoginCredentialDto,
|
||||
@Res() response: Response,
|
||||
): Promise<LoginResponseDto> {
|
||||
const loginResponse = await this.authService.login(loginCredential);
|
||||
@@ -42,7 +42,9 @@ export class AuthController {
|
||||
|
||||
@Post('/admin-sign-up')
|
||||
@ApiBadRequestResponse({ description: 'The server already has an admin' })
|
||||
async adminSignUp(@Body(ValidationPipe) signUpCredential: SignUpDto): Promise<AdminSignupResponseDto> {
|
||||
async adminSignUp(
|
||||
@Body(new ValidationPipe({ transform: true })) signUpCredential: SignUpDto,
|
||||
): Promise<AdminSignupResponseDto> {
|
||||
return await this.authService.adminSignUp(signUpCredential);
|
||||
}
|
||||
|
||||
@@ -61,8 +63,7 @@ export class AuthController {
|
||||
|
||||
const status = new LogoutResponseDto(true);
|
||||
|
||||
response.send(status)
|
||||
response.send(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class LoginCredentialDto {
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'testuser@email.com' })
|
||||
@Transform(({ value }) => value?.toLowerCase())
|
||||
email!: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNotEmpty, IsEmail } from 'class-validator';
|
||||
|
||||
export class SignUpDto {
|
||||
@IsEmail()
|
||||
@ApiProperty({ example: 'testuser@email.com' })
|
||||
@Transform(({ value }) => value?.toLowerCase())
|
||||
email!: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNotEmpty, IsEmail } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsEmail()
|
||||
@Transform(({ value }) => value?.toLowerCase())
|
||||
@ApiProperty({ example: 'testuser@email.com' })
|
||||
email!: string;
|
||||
|
||||
|
||||
@@ -61,7 +61,9 @@ export class UserController {
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminRolesGuard)
|
||||
@Post()
|
||||
async createUser(@Body(ValidationPipe) createUserDto: CreateUserDto): Promise<UserResponseDto> {
|
||||
async createUser(
|
||||
@Body(new ValidationPipe({ transform: true })) createUserDto: CreateUserDto,
|
||||
): Promise<UserResponseDto> {
|
||||
return await this.userService.createUser(createUserDto);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import { AppLoggerMiddleware } from './middlewares/app-logger.middleware';
|
||||
|
||||
BullModule.forRootAsync({
|
||||
useFactory: async () => ({
|
||||
prefix: 'immich_bull',
|
||||
redis: {
|
||||
host: process.env.REDIS_HOSTNAME || 'immich_redis',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface IServerVersion {
|
||||
|
||||
export const serverVersion: IServerVersion = {
|
||||
major: 1,
|
||||
minor: 22,
|
||||
minor: 24,
|
||||
patch: 0,
|
||||
build: 0,
|
||||
};
|
||||
|
||||
@@ -30,10 +30,19 @@ export class BackgroundTaskProcessor {
|
||||
});
|
||||
|
||||
// TODO: what if there is no asset.resizePath. Should fail the Job?
|
||||
// => panoti report: Job not fail
|
||||
if (asset.resizePath) {
|
||||
fs.unlink(asset.resizePath, (err) => {
|
||||
if (err) {
|
||||
console.log('error deleting ', asset.originalPath);
|
||||
console.log('error deleting ', asset.resizePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (asset.webpPath) {
|
||||
fs.unlink(asset.webpPath, (err) => {
|
||||
if (err) {
|
||||
console.log('error deleting ', asset.webpPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
TypeOrmModule.forFeature([UserEntity, ExifEntity, AssetEntity, SmartInfoEntity]),
|
||||
BullModule.forRootAsync({
|
||||
useFactory: async () => ({
|
||||
prefix: 'immich_bull',
|
||||
redis: {
|
||||
host: process.env.REDIS_HOSTNAME || 'immich_redis',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
thumbnailGeneratorQueueName,
|
||||
JpegGeneratorProcessor,
|
||||
} from '@app/job';
|
||||
import { mapAsset } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
|
||||
|
||||
@Processor(thumbnailGeneratorQueueName)
|
||||
export class ThumbnailGeneratorProcessor {
|
||||
@@ -29,7 +30,7 @@ export class ThumbnailGeneratorProcessor {
|
||||
@InjectQueue(thumbnailGeneratorQueueName)
|
||||
private thumbnailGeneratorQueue: Queue,
|
||||
|
||||
private wsCommunicateionGateway: CommunicationGateway,
|
||||
private wsCommunicationGateway: CommunicationGateway,
|
||||
|
||||
@InjectQueue(metadataExtractionQueueName)
|
||||
private metadataExtractionQueue: Queue,
|
||||
@@ -68,7 +69,9 @@ export class ThumbnailGeneratorProcessor {
|
||||
);
|
||||
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
|
||||
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
|
||||
this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset));
|
||||
this.wsCommunicationGateway.server
|
||||
.to(asset.userId)
|
||||
.emit('on_upload_success', JSON.stringify(mapAsset(asset)));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -99,7 +102,9 @@ export class ThumbnailGeneratorProcessor {
|
||||
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
|
||||
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
|
||||
|
||||
this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset));
|
||||
this.wsCommunicationGateway.server
|
||||
.to(asset.userId)
|
||||
.emit('on_upload_success', JSON.stringify(mapAsset(asset)));
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<p class="text-sm">USER LIST</p>
|
||||
|
||||
<table class="text-left w-full my-4">
|
||||
<thead class="border rounded-md mb-2 bg-gray-50 flex text-immich-primary w-full h-12 ">
|
||||
<tr class="flex w-full place-items-center">
|
||||
|
||||
@@ -1,208 +1,207 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import AsserViewerNavBar from './asser-viewer-nav-bar.svelte';
|
||||
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
|
||||
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
|
||||
import PhotoViewer from './photo-viewer.svelte';
|
||||
import DetailPanel from './detail-panel.svelte';
|
||||
import { downloadAssets } from '$lib/stores/download';
|
||||
import VideoViewer from './video-viewer.svelte';
|
||||
import { api, AssetResponseDto, AssetTypeEnum } from '@api';
|
||||
import { browser } from '$app/env';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import AsserViewerNavBar from './asser-viewer-nav-bar.svelte';
|
||||
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
|
||||
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
|
||||
import PhotoViewer from './photo-viewer.svelte';
|
||||
import DetailPanel from './detail-panel.svelte';
|
||||
import { downloadAssets } from '$lib/stores/download';
|
||||
import VideoViewer from './video-viewer.svelte';
|
||||
import { api, AssetResponseDto, AssetTypeEnum } from '@api';
|
||||
import { browser } from '$app/env';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let asset: AssetResponseDto;
|
||||
|
||||
let halfLeftHover = false;
|
||||
let halfRightHover = false;
|
||||
let isShowDetail = false;
|
||||
let halfLeftHover = false;
|
||||
let halfRightHover = false;
|
||||
let isShowDetail = false;
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key));
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key));
|
||||
}
|
||||
});
|
||||
|
||||
const handleKeyboardPress = (key: string) => {
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
closeViewer();
|
||||
return;
|
||||
case 'i':
|
||||
isShowDetail = !isShowDetail;
|
||||
return;
|
||||
case 'ArrowLeft':
|
||||
navigateAssetBackward();
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
navigateAssetForward();
|
||||
return;
|
||||
}
|
||||
};
|
||||
const handleKeyboardPress = (key: string) => {
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
closeViewer();
|
||||
return;
|
||||
case 'i':
|
||||
isShowDetail = !isShowDetail;
|
||||
return;
|
||||
case 'ArrowLeft':
|
||||
navigateAssetBackward();
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
navigateAssetForward();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
dispatch('close');
|
||||
};
|
||||
const closeViewer = () => {
|
||||
dispatch('close');
|
||||
};
|
||||
|
||||
const navigateAssetForward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-forward');
|
||||
};
|
||||
const navigateAssetForward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-forward');
|
||||
};
|
||||
|
||||
const navigateAssetBackward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-backward');
|
||||
};
|
||||
const navigateAssetBackward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-backward');
|
||||
};
|
||||
|
||||
const showDetailInfoHandler = () => {
|
||||
isShowDetail = !isShowDetail;
|
||||
};
|
||||
const showDetailInfoHandler = () => {
|
||||
isShowDetail = !isShowDetail;
|
||||
};
|
||||
|
||||
const downloadFile = async () => {
|
||||
try {
|
||||
const imageName = asset.exifInfo?.imageName ? asset.exifInfo?.imageName : asset.id;
|
||||
const imageExtension = asset.originalPath.split('.')[1];
|
||||
const imageFileName = imageName + '.' + imageExtension;
|
||||
const downloadFile = async () => {
|
||||
try {
|
||||
console.log(asset.exifInfo);
|
||||
const imageName = asset.exifInfo?.imageName ? asset.exifInfo?.imageName : asset.id;
|
||||
const imageExtension = asset.originalPath.split('.')[1];
|
||||
const imageFileName = imageName + '.' + imageExtension;
|
||||
|
||||
// If assets is already download -> return;
|
||||
if ($downloadAssets[imageFileName]) {
|
||||
return;
|
||||
}
|
||||
// If assets is already download -> return;
|
||||
if ($downloadAssets[imageFileName]) {
|
||||
return;
|
||||
}
|
||||
|
||||
$downloadAssets[imageFileName] = 0;
|
||||
$downloadAssets[imageFileName] = 0;
|
||||
|
||||
const { data, status } = await api.assetApi.downloadFile(
|
||||
asset.deviceAssetId,
|
||||
asset.deviceId,
|
||||
false,
|
||||
false,
|
||||
{
|
||||
responseType: 'blob',
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
if (progressEvent.lengthComputable) {
|
||||
const total = progressEvent.total;
|
||||
const current = progressEvent.loaded;
|
||||
let percentCompleted = Math.floor((current / total) * 100);
|
||||
const {data, status} = await api.assetApi.downloadFile(
|
||||
asset.deviceAssetId,
|
||||
asset.deviceId,
|
||||
false,
|
||||
false,
|
||||
{
|
||||
responseType: 'blob',
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
if (progressEvent.lengthComputable) {
|
||||
const total = progressEvent.total;
|
||||
const current = progressEvent.loaded;
|
||||
$downloadAssets[imageFileName] = Math.floor((current / total) * 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$downloadAssets[imageFileName] = percentCompleted;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
if (!(data instanceof Blob)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(data instanceof Blob)) {
|
||||
return;
|
||||
}
|
||||
if (status === 200) {
|
||||
const fileUrl = URL.createObjectURL(data);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = fileUrl;
|
||||
anchor.download = imageFileName;
|
||||
|
||||
if (status === 200) {
|
||||
const fileUrl = URL.createObjectURL(data);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = fileUrl;
|
||||
anchor.download = imageFileName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(fileUrl);
|
||||
|
||||
URL.revokeObjectURL(fileUrl);
|
||||
|
||||
// Remove item from download list
|
||||
setTimeout(() => {
|
||||
const copy = $downloadAssets;
|
||||
delete copy[imageFileName];
|
||||
$downloadAssets = copy;
|
||||
}, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error downloading file ', e);
|
||||
}
|
||||
};
|
||||
// Remove item from download list
|
||||
setTimeout(() => {
|
||||
const copy = $downloadAssets;
|
||||
delete copy[imageFileName];
|
||||
$downloadAssets = copy;
|
||||
}, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error downloading file ', e);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed h-screen w-screen top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4 "
|
||||
id="immich-asset-viewer"
|
||||
class="fixed h-screen w-screen top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4 "
|
||||
>
|
||||
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
|
||||
<AsserViewerNavBar
|
||||
on:goBack={closeViewer}
|
||||
on:showDetail={showDetailInfoHandler}
|
||||
on:download={downloadFile}
|
||||
/>
|
||||
</div>
|
||||
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
|
||||
<AsserViewerNavBar
|
||||
on:goBack={closeViewer}
|
||||
on:showDetail={showDetailInfoHandler}
|
||||
on:download={downloadFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 ${
|
||||
asset.type == AssetTypeEnum.Video ? '' : 'z-[999]'
|
||||
<div
|
||||
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 ${
|
||||
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
|
||||
}`}
|
||||
on:mouseenter={() => {
|
||||
on:mouseenter={() => {
|
||||
halfLeftHover = true;
|
||||
halfRightHover = false;
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
on:mouseleave={() => {
|
||||
halfLeftHover = false;
|
||||
}}
|
||||
on:click={navigateAssetBackward}
|
||||
>
|
||||
<button
|
||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
|
||||
class:navigation-button-hover={halfLeftHover}
|
||||
on:click={navigateAssetBackward}
|
||||
>
|
||||
<ChevronLeft size="36" />
|
||||
</button>
|
||||
</div>
|
||||
on:click={navigateAssetBackward}
|
||||
>
|
||||
<button
|
||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
|
||||
class:navigation-button-hover={halfLeftHover}
|
||||
on:click={navigateAssetBackward}
|
||||
>
|
||||
<ChevronLeft size="36"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row-start-1 row-span-full col-start-1 col-span-4">
|
||||
{#key asset.id}
|
||||
{#if asset.type == AssetTypeEnum.Image}
|
||||
<PhotoViewer assetId={asset.id} deviceId={asset.deviceId} on:close={closeViewer} />
|
||||
{:else}
|
||||
<VideoViewer assetId={asset.id} on:close={closeViewer} />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
<div class="row-start-1 row-span-full col-start-1 col-span-4">
|
||||
{#key asset.id}
|
||||
{#if asset.type === AssetTypeEnum.Image}
|
||||
<PhotoViewer assetId={asset.id} deviceId={asset.deviceId} on:close={closeViewer}/>
|
||||
{:else}
|
||||
<VideoViewer assetId={asset.id} on:close={closeViewer}/>
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end ${
|
||||
asset.type == AssetTypeEnum.Video ? '' : 'z-[500]'
|
||||
<div
|
||||
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end ${
|
||||
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
|
||||
}`}
|
||||
on:click={navigateAssetForward}
|
||||
on:mouseenter={() => {
|
||||
on:click={navigateAssetForward}
|
||||
on:mouseenter={() => {
|
||||
halfLeftHover = false;
|
||||
halfRightHover = true;
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
on:mouseleave={() => {
|
||||
halfRightHover = false;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4 z-[1000]"
|
||||
class:navigation-button-hover={halfRightHover}
|
||||
on:click={navigateAssetForward}
|
||||
>
|
||||
<ChevronRight size="36" />
|
||||
</button>
|
||||
</div>
|
||||
>
|
||||
<button
|
||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4 z-[1000]"
|
||||
class:navigation-button-hover={halfRightHover}
|
||||
on:click={navigateAssetForward}
|
||||
>
|
||||
<ChevronRight size="36"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if isShowDetail}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="detail-panel"
|
||||
class="bg-immich-bg w-[360px] row-span-full transition-all "
|
||||
translate="yes"
|
||||
>
|
||||
<DetailPanel {asset} on:close={() => (isShowDetail = false)} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if isShowDetail}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="detail-panel"
|
||||
class="bg-immich-bg w-[360px] row-span-full transition-all "
|
||||
translate="yes"
|
||||
>
|
||||
<DetailPanel {asset} on:close={() => (isShowDetail = false)}/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.navigation-button-hover {
|
||||
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity));
|
||||
transition: all 150ms;
|
||||
}
|
||||
.navigation-button-hover {
|
||||
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity));
|
||||
transition: all 150ms;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8">
|
||||
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
|
||||
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" />
|
||||
<h1 class="text-2xl text-immich-primary font-medium">Chage Password</h1>
|
||||
<h1 class="text-2xl text-immich-primary font-medium">Change Password</h1>
|
||||
|
||||
<p class="text-sm border rounded-md p-4 font-mono text-gray-600">
|
||||
Hi {user.firstName}
|
||||
|
||||
@@ -1,103 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import AccountEditOutline from 'svelte-material-icons/AccountEditOutline.svelte';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import AccountEditOutline from 'svelte-material-icons/AccountEditOutline.svelte';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
export let user: UserResponseDto;
|
||||
|
||||
let error: string;
|
||||
let success: string;
|
||||
let error: string;
|
||||
let success: string;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const editUser = async (event: SubmitEvent) => {
|
||||
// eslint-disable-next-line no-undef
|
||||
const editUser = async (event: SubmitEvent) => {
|
||||
try {
|
||||
const formElement = event.target as HTMLFormElement;
|
||||
const form = new FormData(formElement);
|
||||
|
||||
const formElement = event.target as HTMLFormElement;
|
||||
const form = new FormData(formElement);
|
||||
const firstName = form.get('firstName');
|
||||
const lastName = form.get('lastName');
|
||||
|
||||
const firstName = form.get('firstName');
|
||||
const lastName = form.get('lastName');
|
||||
const { status } = await api.userApi.updateUser({
|
||||
id: user.id,
|
||||
firstName: firstName!.toString(),
|
||||
lastName: lastName!.toString()
|
||||
});
|
||||
|
||||
if (status == 200) {
|
||||
dispatch('edit-success');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error updating user ', e);
|
||||
}
|
||||
};
|
||||
|
||||
const {status} = await api.userApi.updateUser({
|
||||
id: user.id,
|
||||
firstName: firstName.toString(),
|
||||
lastName: lastName.toString()
|
||||
}).catch(e => console.log("Error updating user ", e));
|
||||
const resetPassword = async () => {
|
||||
try {
|
||||
if (window.confirm('Do you want to reset the user password?')) {
|
||||
const defaultPassword = 'password';
|
||||
|
||||
if (status == 200) {
|
||||
dispatch('edit-success');
|
||||
}
|
||||
}
|
||||
const { status } = await api.userApi.updateUser({
|
||||
id: user.id,
|
||||
password: defaultPassword,
|
||||
shouldChangePassword: true
|
||||
});
|
||||
|
||||
const resetPassword = async () => {
|
||||
const defaultPassword = 'password'
|
||||
|
||||
const {status} = await api.userApi.updateUser({
|
||||
id: user.id,
|
||||
password: defaultPassword,
|
||||
shouldChangePassword: true,
|
||||
|
||||
}).catch(e => console.log("Error updating user ", e));
|
||||
|
||||
if (status == 200) {
|
||||
dispatch('reset-password-success');
|
||||
}
|
||||
}
|
||||
if (status == 200) {
|
||||
dispatch('reset-password-success');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error reseting user password', e);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-3xl py-8">
|
||||
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
|
||||
<!-- <img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo"/>-->
|
||||
<AccountEditOutline size="4em" color="#4250affe"/>
|
||||
<h1 class="text-2xl text-immich-primary font-medium">Edit user</h1>
|
||||
</div>
|
||||
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
|
||||
<!-- <img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo"/>-->
|
||||
<AccountEditOutline size="4em" color="#4250affe" />
|
||||
<h1 class="text-2xl text-immich-primary font-medium">Edit user</h1>
|
||||
</div>
|
||||
|
||||
<form on:submit|preventDefault={editUser} autocomplete="off">
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="email">Email
|
||||
(cannot change)</label>
|
||||
<input class="immich-form-input disabled:bg-gray-200 hover:cursor-not-allowed"
|
||||
id="email" name="email"
|
||||
type="email" disabled
|
||||
bind:value={user.email}/>
|
||||
</div>
|
||||
<form on:submit|preventDefault={editUser} autocomplete="off">
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="email">Email (cannot change)</label>
|
||||
<input
|
||||
class="immich-form-input disabled:bg-gray-200 hover:cursor-not-allowed"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
disabled
|
||||
bind:value={user.email}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="firstName">First Name</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
bind:value={user.firstName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="firstName">First Name</label>
|
||||
<input class="immich-form-input" id="firstName" name="firstName" type="text" required
|
||||
bind:value={user.firstName}/>
|
||||
</div>
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="lastName">Last Name</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
bind:value={user.lastName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="lastName">Last Name</label>
|
||||
<input class="immich-form-input" id="lastName" name="lastName" type="text" required
|
||||
bind:value={user.lastName}/>
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="text-red-400 ml-4 text-sm">{error}</p>
|
||||
{/if}
|
||||
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 ml-4 text-sm">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<p class="text-immich-primary ml-4 text-sm">{success}</p>
|
||||
{/if}
|
||||
<div class="flex w-full px-4 gap-4 mt-8">
|
||||
<button on:click={resetPassword}
|
||||
class="flex-1 transition-colors bg-[#F9DEDC] hover:bg-red-50 text-[#410E0B] px-6 py-3 rounded-full w-full font-medium"
|
||||
|
||||
>Reset password
|
||||
</button
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 transition-colors bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
|
||||
>Confirm
|
||||
</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{#if success}
|
||||
<p class="text-immich-primary ml-4 text-sm">{success}</p>
|
||||
{/if}
|
||||
<div class="flex w-full px-4 gap-4 mt-8">
|
||||
<button
|
||||
on:click={resetPassword}
|
||||
class="flex-1 transition-colors bg-[#F9DEDC] hover:bg-red-50 text-[#410E0B] px-6 py-3 rounded-full w-full font-medium"
|
||||
>Reset password
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 transition-colors bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
|
||||
>Confirm
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@ async function fileUploader(asset: File, uploadType: UploadType) {
|
||||
let exifData = null;
|
||||
|
||||
if (assetType !== 'VIDEO') {
|
||||
exifData = await exifr.parse(asset);
|
||||
exifData = await exifr.parse(asset).catch((e) => console.log('error parsing exif', e));
|
||||
}
|
||||
|
||||
const createdAt =
|
||||
|
||||
@@ -1,181 +1,181 @@
|
||||
<script context="module" lang="ts">
|
||||
import type { Load } from '@sveltejs/kit';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import { browser } from '$app/env';
|
||||
import type { Load } from '@sveltejs/kit';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import { browser } from '$app/env';
|
||||
|
||||
export const load: Load = async ({fetch, session}) => {
|
||||
if (!browser && !session.user) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
export const load: Load = async ({ fetch, session }) => {
|
||||
if (!browser && !session.user) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
const user: UserResponseDto = await fetch('/data/user/get-my-user-info').then((r) =>
|
||||
r.json()
|
||||
);
|
||||
const allUsers: UserResponseDto[] = await fetch('/data/user/get-all-users?isAll=false').then(
|
||||
(r) => r.json()
|
||||
);
|
||||
|
||||
if (!user.isAdmin) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/photos'
|
||||
};
|
||||
}
|
||||
|
||||
const user: UserResponseDto = await fetch('/data/user/get-my-user-info').then((r) => r.json());
|
||||
const allUsers: UserResponseDto[] = await fetch<UserResponseDto[]>('/data/user/get-all-users?isAll=false').then((r) => r.json());
|
||||
|
||||
if (!user.isAdmin) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/photos'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
props: {
|
||||
user: user,
|
||||
allUsers: allUsers
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
};
|
||||
return {
|
||||
status: 200,
|
||||
props: {
|
||||
user: user,
|
||||
allUsers: allUsers
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login'
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
|
||||
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
|
||||
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
|
||||
import UserManagement from '$lib/components/admin-page/user-management.svelte';
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
|
||||
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
|
||||
import StatusBox from '$lib/components/shared-components/status-box.svelte';
|
||||
import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
|
||||
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
|
||||
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
|
||||
import UserManagement from '$lib/components/admin-page/user-management.svelte';
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
|
||||
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
|
||||
import StatusBox from '$lib/components/shared-components/status-box.svelte';
|
||||
|
||||
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
|
||||
|
||||
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
|
||||
export let user: UserResponseDto;
|
||||
export let allUsers: UserResponseDto[];
|
||||
|
||||
export let user: UserResponseDto;
|
||||
export let allUsers: UserResponseDto[];
|
||||
let editUser: UserResponseDto;
|
||||
|
||||
let editUser: UserResponseDto;
|
||||
let shouldShowEditUserForm = false;
|
||||
let shouldShowCreateUserForm = false;
|
||||
let shouldShowInfoPanel = false;
|
||||
|
||||
let shouldShowEditUserForm = false;
|
||||
let shouldShowCreateUserForm = false;
|
||||
let shouldShowInfoPanel = false;
|
||||
const onButtonClicked = (buttonType: CustomEvent) => {
|
||||
selectedAction = buttonType.detail['actionType'] as AdminSideBarSelection;
|
||||
};
|
||||
|
||||
const onButtonClicked = (buttonType: CustomEvent) => {
|
||||
selectedAction = buttonType.detail['actionType'] as AdminSideBarSelection;
|
||||
};
|
||||
onMount(() => {
|
||||
selectedAction = AdminSideBarSelection.USER_MANAGEMENT;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
selectedAction = AdminSideBarSelection.USER_MANAGEMENT;
|
||||
});
|
||||
const onUserCreated = async () => {
|
||||
const { data } = await api.userApi.getAllUsers(false);
|
||||
allUsers = data;
|
||||
shouldShowCreateUserForm = false;
|
||||
};
|
||||
|
||||
const onUserCreated = async () => {
|
||||
const {data} = await api.userApi.getAllUsers(false);
|
||||
allUsers = data;
|
||||
shouldShowCreateUserForm = false;
|
||||
};
|
||||
const editUserHandler = async (event: CustomEvent) => {
|
||||
const { user } = event.detail;
|
||||
editUser = user;
|
||||
shouldShowEditUserForm = true;
|
||||
};
|
||||
|
||||
const editUserHandler = async (event: CustomEvent) => {
|
||||
const {user} = event.detail;
|
||||
editUser = user;
|
||||
shouldShowEditUserForm = true;
|
||||
};
|
||||
const onEditUserSuccess = async () => {
|
||||
const { data } = await api.userApi.getAllUsers(false);
|
||||
allUsers = data;
|
||||
shouldShowEditUserForm = false;
|
||||
};
|
||||
|
||||
const onEditUserSuccess = async () => {
|
||||
const {data} = await api.userApi.getAllUsers(false);
|
||||
allUsers = data;
|
||||
shouldShowEditUserForm = false;
|
||||
}
|
||||
|
||||
const onEditPasswordSuccess = async () => {
|
||||
const {data} = await api.userApi.getAllUsers(false);
|
||||
allUsers = data;
|
||||
shouldShowEditUserForm = false;
|
||||
shouldShowInfoPanel = true;
|
||||
}
|
||||
const onEditPasswordSuccess = async () => {
|
||||
const { data } = await api.userApi.getAllUsers(false);
|
||||
allUsers = data;
|
||||
shouldShowEditUserForm = false;
|
||||
shouldShowInfoPanel = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Administration - Immich</title>
|
||||
<title>Administration - Immich</title>
|
||||
</svelte:head>
|
||||
|
||||
<NavigationBar {user}/>
|
||||
<NavigationBar {user} />
|
||||
|
||||
{#if shouldShowCreateUserForm}
|
||||
<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}>
|
||||
<CreateUserForm on:user-created={onUserCreated}/>
|
||||
</FullScreenModal>
|
||||
<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}>
|
||||
<CreateUserForm on:user-created={onUserCreated} />
|
||||
</FullScreenModal>
|
||||
{/if}
|
||||
|
||||
{#if shouldShowEditUserForm}
|
||||
<FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}>
|
||||
<EditUserForm user={editUser} on:edit-success={onEditUserSuccess}
|
||||
on:reset-password-success={onEditPasswordSuccess}/>
|
||||
</FullScreenModal>
|
||||
<FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}>
|
||||
<EditUserForm
|
||||
user={editUser}
|
||||
on:edit-success={onEditUserSuccess}
|
||||
on:reset-password-success={onEditPasswordSuccess}
|
||||
/>
|
||||
</FullScreenModal>
|
||||
{/if}
|
||||
|
||||
{#if shouldShowInfoPanel}
|
||||
<FullScreenModal on:clickOutside={() => (shouldShowInfoPanel = false)}>
|
||||
<FullScreenModal on:clickOutside={() => (shouldShowInfoPanel = false)}>
|
||||
<div class="border bg-white shadow-sm w-[500px] rounded-3xl p-8 text-sm">
|
||||
<h1 class="font-bold text-immich-primary text-lg mb-4">Password reset success</h1>
|
||||
|
||||
<div class="border bg-white shadow-sm w-[500px] rounded-3xl p-8 text-sm">
|
||||
<h1 class="font-bold text-immich-primary text-lg mb-4">Password reset success</h1>
|
||||
<p>
|
||||
The user's password has been reset to the default <code
|
||||
class="font-bold bg-gray-200 px-2 py-1 rounded-md text-immich-primary">password</code
|
||||
>
|
||||
<br />
|
||||
Please inform the user, and they will need to change the password at the next log-on.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The user's password has been reset to the default <code
|
||||
class="font-bold bg-gray-200 px-2 py-1 rounded-md text-immich-primary">password</code>
|
||||
<br>
|
||||
Please inform the user, and they will need to change the password at the next log-on.
|
||||
</p>
|
||||
|
||||
<div class="flex w-full">
|
||||
<button
|
||||
on:click={() => shouldShowInfoPanel = false}
|
||||
class="mt-6 bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
|
||||
>Done
|
||||
</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenModal>
|
||||
<div class="flex w-full">
|
||||
<button
|
||||
on:click={() => (shouldShowInfoPanel = false)}
|
||||
class="mt-6 bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
|
||||
>Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenModal>
|
||||
{/if}
|
||||
|
||||
|
||||
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen">
|
||||
<section id="admin-sidebar" class="pt-8 pr-6 flex flex-col">
|
||||
<SideBarButton
|
||||
title="User"
|
||||
logo={AccountMultipleOutline}
|
||||
actionType={AdminSideBarSelection.USER_MANAGEMENT}
|
||||
isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
|
||||
on:selected={onButtonClicked}
|
||||
/>
|
||||
<section id="admin-sidebar" class="pt-8 pr-6 flex flex-col">
|
||||
<SideBarButton
|
||||
title="User"
|
||||
logo={AccountMultipleOutline}
|
||||
actionType={AdminSideBarSelection.USER_MANAGEMENT}
|
||||
isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
|
||||
on:selected={onButtonClicked}
|
||||
/>
|
||||
|
||||
<div class="mb-6 mt-auto">
|
||||
<StatusBox/>
|
||||
</div>
|
||||
</section>
|
||||
<section class="overflow-y-auto relative">
|
||||
<div id="setting-title" class="pt-10 fixed w-full z-50 bg-immich-bg">
|
||||
<h1 class="text-lg ml-8 mb-4 text-immich-primary font-medium">{selectedAction}</h1>
|
||||
<hr/>
|
||||
</div>
|
||||
|
||||
|
||||
<section id="setting-content" class="relative pt-[85px] flex place-content-center">
|
||||
<section class="w-[800px] pt-4">
|
||||
{#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
|
||||
<UserManagement
|
||||
{allUsers}
|
||||
on:create-user={() => (shouldShowCreateUserForm = true)}
|
||||
on:edit-user={editUserHandler}
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
<div class="mb-6 mt-auto">
|
||||
<StatusBox />
|
||||
</div>
|
||||
</section>
|
||||
<section class="overflow-y-auto relative">
|
||||
<div id="setting-title" class="pt-10 fixed w-full z-50 bg-immich-bg">
|
||||
<h1 class="text-lg ml-8 mb-4 text-immich-primary font-medium">{selectedAction}</h1>
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
<section id="setting-content" class="relative pt-[85px] flex place-content-center">
|
||||
<section class="w-[800px] pt-4">
|
||||
{#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
|
||||
<UserManagement
|
||||
{allUsers}
|
||||
on:create-user={() => (shouldShowCreateUserForm = true)}
|
||||
on:edit-user={editUserHandler}
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -39,12 +39,9 @@
|
||||
export let user: UserResponseDto;
|
||||
|
||||
const onSuccessHandler = async () => {
|
||||
/** Svelte route fetch */
|
||||
const res = await fetch('/auth/logout', { method: 'POST' });
|
||||
|
||||
if (res.status == 200 && res.statusText == 'OK') {
|
||||
goto('/auth/login');
|
||||
}
|
||||
await fetch('auth/logout', { method: 'POST' });
|
||||
|
||||
goto('/auth/login');
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user