Compare commits

...

25 Commits

Author SHA1 Message Date
Alex
4309104925 118 - Implement shared album feature (#124)
* New features 
  - Share album. Users can now create albums to share with existing people on the network.
  - Owner can delete the album.
  - Owner can invite the additional users to the album.
  - Shared users and the owner can add additional assets to the album.
* In the asset viewer, the user can swipe up to see detailed information and swip down to dismiss.
* Several UI enhancements.
2022-04-23 21:08:45 -05:00
Alex Tran
a3b84b3ca7 Update readme 2022-04-05 17:29:02 -05:00
Alex Tran
f6630163b1 update readme 2022-04-05 17:27:37 -05:00
Chiogros
aebeb37fb0 Legacy CPUs architecture alternative tip. (#93) 2022-04-05 17:26:42 -05:00
Alex
b74ad69288 Fixed duplicated filename on upload 2022-04-05 14:57:54 -05:00
Alex Tran
b6579cd38e Fixed incorrect image name when push to dockerhub 2022-04-05 11:43:09 -05:00
Alex Tran
46a2032b9a Update release workflow 2022-04-05 11:08:30 -05:00
Alex Tran
0eb548f115 Update readme 2022-04-05 11:04:53 -05:00
Alex
c7dff229db Up minor v1.6.0 (#113) 2022-04-05 10:34:54 -05:00
Alex
8e80825b4f Build and tag docker image for Dockerhub release (#111)
* Clean up Dockerfile and added action to build microservice latest
* Combine build microservices and server into the same action
* Added build and push release version for microservices
2022-04-05 10:16:15 -05:00
Constantin Kraft
a1481c1113 Fix typo: Reserve -> Reverse (geocoding) (#112) 2022-04-05 10:11:40 -05:00
Alex
3bdcdef198 Fixed backup stuck at unsupported format (#108)
* Added webp as supported file type, allow continue upload when an image fail

* Added webp as supported file type, allow continue upload when an image fail

* Solved issue with bad assets cause backup to stop
2022-04-04 23:37:48 -05:00
Alex
b69f6e0df7 Update inline font for f-droid publication metric (#107)
* Added local font
* Up Patch 1.5.1+9
2022-04-04 09:08:53 -05:00
Alex
be2794a372 Optimization/fix slow backup when asset list is long. (#104)
* Handle pause/restart listening to event on_upload_success and reload asset list after navigating back from BackupControllerPage
* Remove unused api endpoint
2022-04-03 12:31:45 -05:00
Alex Tran
2ff25b49f4 Up Minor 1.5.0+8 2022-04-02 12:46:29 -05:00
Alex Tran
135d72d4cd Fixed issue with docker-compose cannot navigate to relative path of Dockercompose file in issue #90 2022-04-02 12:37:57 -05:00
Alex
90ef64efa3 Download asset to local and error fixing (#100)
* Update photo_manager pub package
* Added download endpoint for assets
* Successfully save a photo to the local device's gallery
* Save save a video to the local device's gallery
* Fixed #97
* Added download loading indicator
* Refactor and increase the font size for curated search thumbnail images
* Reposition loading animation on the search result page
2022-04-02 12:31:53 -05:00
Alex Tran
60df387459 Update fdroid app description 2022-03-30 13:14:09 -05:00
Alex Tran
fc1acf6f01 Remove release build on github action 2022-03-29 22:10:21 -05:00
Alex Tran
cfc5229964 Fixed issue with container cannot find module 2022-03-29 20:25:00 -05:00
Alex Tran
f9ddeac265 Fixed issue with container cannot find module 2022-03-29 20:17:40 -05:00
Alex
8d7c576037 Added required setup for f-droid (#88)
* Added required setup for f-droid

* Added distributionSha256Sum tp gradle-wrapper.properties
2022-03-29 14:13:47 -05:00
Alex
fccdbdd66a Update production dockerfile for a cleaner look (#86) 2022-03-29 08:56:59 -05:00
Alex
23ba651705 Fixed npm run start:prod not able to find build directory (#83) 2022-03-28 21:00:17 -05:00
Alex
ac0ad98b55 Fix docker-compose in production (#81)
* Fixed problem with docker-compose not updating new files in the multi-stage build.
* Update readme with a new screenshot
2022-03-28 15:21:15 -05:00
159 changed files with 4656 additions and 885 deletions

View File

@@ -0,0 +1,64 @@
name: Build and Push Docker Image - Latest
on:
workflow_dispatch:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build_and_push_server_latest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main" # branch
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1.6.0
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Immich
uses: docker/build-push-action@v2.10.0
with:
context: ./server
file: ./server/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-server:latest
build_and_push_microservice_latest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main" # branch
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1.6.0
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Microservices
uses: docker/build-push-action@v2.10.0
with:
context: ./microservices
file: ./microservices/Dockerfile
platforms: linux/arm/v7,linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-microservices:latest

View File

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

View File

@@ -0,0 +1,83 @@
name: Build and push Docker image - Release
on:
workflow_dispatch:
release:
types: [published]
jobs:
build_and_push_server_release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main"
fetch-depth: 0
- name: 'Get Previous tag'
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1.6.0
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-server release
uses: docker/build-push-action@v2.10.0
with:
context: ./server
file: ./server/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-server:${{ steps.previoustag.outputs.tag }}
build_and_push_microservice_release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main"
fetch-depth: 0
- name: 'Get Previous tag'
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1.6.0
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-microservices release
uses: docker/build-push-action@v2.10.0
with:
context: ./microservices
file: ./microservices/Dockerfile
platforms: linux/arm/v7,linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-microservices:${{ steps.previoustag.outputs.tag }}

View File

@@ -2,7 +2,13 @@ dev:
docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-update:
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale:
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich_server=3 --remove-orphans
prod:
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
prod-scale:
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich_server=3 --scale immich_microservices=3 --remove-orphans

View File

@@ -1,13 +1,17 @@
# Deployment checklist for iOS/Android/Server
[] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
[ ] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
[] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service
[ ] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service
[] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
[ ] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
[] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
[ ] Up version in [docker/docker-compose.dev.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
[] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
[ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
[ ] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
[ ] Add changelog to [Android Fastlane F-droid folder](/mobile/android/fastlane/metadata/android/en-US/changelogs)
All of the version should be the same.

View File

@@ -34,7 +34,9 @@ Loading ~4000 images/videos
<p align="left">
<img src="design/nsc1.png" width="150" title="Login With Custom URL">
<img src="design/nsc2.png" width="150" title="Backup Setting Info">
<img src="design/nsc3.png" width="150" title="Multiple seelct">
<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>
@@ -47,7 +49,8 @@ This project is under heavy development, there will be continous functions, feat
# Features
- Upload and view assets(videos/images).
- Upload and view assets (videos/images).
- Download asset to local device.
- Multi-user supported.
- Quick navigation with drag scroll bar.
- Auto Backup.
@@ -57,11 +60,11 @@ This project is under heavy development, there will be continous functions, feat
- Image Tagging/Classification based on ImageNet dataset
- Object detection based on COCO SSD.
- Search assets based on tags and exif data (lens, make, model, orientation)
- Upload assets from your local computer/server using [immich cli tools](https://www.npmjs.com/package/immich)
- [Optional] Reserve geocoding using Mapbox (Generous free-tier of 100,000 search/month)
- [Optional] Reverse geocoding using Mapbox (Generous free-tier of 100,000 search/month)
- Show asset's location information on map (OpenStreetMap).
- Show curated places on the search page
- Show curated objects on the search page
- Shared album with users on the same server
# Development
@@ -110,18 +113,37 @@ curl --location --request POST 'http://your-server-ip:2283/auth/signUp' \
## Run mobile app
### Android
## F-Droid
You can get the app on F-droid by cliking the image below.
Download `apk` in release tab and run on your phone. You can follow this guide on how to do that
[<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)
## Android
#### Download latest `apk` in release tab and run on your phone. You can follow this guide on how to do that
- [Run APK on Android](https://www.lifewire.com/install-apk-on-android-4177185)
### iOS
#### You can also download the app from Google Play Store [here](https://play.google.com/store/apps/details?id=app.alextran.immich)
You can download the app from 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="250" title="Apple App Store">
<img src="design/google-play-qr-code.png" width="200" title="Google Play Store">
<p/>
## iOS
#### You can download the app from 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/>
# Support
@@ -134,14 +156,22 @@ Cheer! 🎉
# Known Issue
TensorFlow doesn't run with older CPU architecture, it requires CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`. Otherwise, switch to a different VM/desktop with different architecture.
## TensorFlow Build Issue
*This is a known issue on RaspberryPi 4 arm64-v7 and incorrect Promox setup*
TensorFlow doesn't run with older CPU architecture, it requires CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`:
```bash
more /proc/cpuinfo | grep flags
```
```
If you are running virtualization in Promox, the VM doesn't have the flag enable.
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.
`Hardware > Processors > Edit > Advanced > Type (dropdown menu) > host`
Otherwise you can:
- edit `docker-compose.yml` file and comment the whole `immich_microservices` service **which will disable machine learning features like object detection and image classification**
- switch to a different VM/desktop with different architecture.

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
design/home-screen.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

BIN
design/nsc4.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

BIN
design/search-screen.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

BIN
design/shared-albums.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View File

@@ -1,6 +1,3 @@
# STAGE
NODE_ENV=development
# Database
DB_USERNAME=postgres
DB_PASSWORD=postgres

View File

@@ -2,11 +2,10 @@ version: "3.8"
services:
immich_server:
image: immich-server-dev:1.3.2
image: immich-server-dev:1.7.0
build:
context: ../server
target: development
dockerfile: ../server/Dockerfile
dockerfile: Dockerfile
command: npm run start:dev
expose:
- "3000"
@@ -16,6 +15,8 @@ services:
- /usr/src/app/node_modules
env_file:
- .env
environment:
- NODE_ENV=development
depends_on:
- redis
- database
@@ -23,11 +24,10 @@ services:
- immich_network
immich_microservices:
image: immich-microservices-dev:1.3.2
image: immich-microservices-dev:1.7.0
build:
context: ../microservices
target: development
dockerfile: ../microservices/Dockerfile
dockerfile: Dockerfile
command: npm run start:dev
expose:
- "3001"
@@ -37,6 +37,8 @@ services:
- /usr/src/app/node_modules
env_file:
- .env
environment:
- NODE_ENV=development
depends_on:
- database
networks:

View File

@@ -2,11 +2,10 @@ version: "3.8"
services:
immich_server:
image: immich-server-dev:1.4.0
image: immich-server-dev:1.7.0
build:
context: ../server
target: development
dockerfile: ../server/Dockerfile
dockerfile: Dockerfile
command: npm run start:dev
expose:
- "3000"
@@ -23,11 +22,10 @@ services:
- immich_network
immich_microservices:
image: immich-microservices-dev:1.4.0
image: immich-microservices-dev:1.7.0
build:
context: ../microservices
target: development
dockerfile: ../microservices/Dockerfile
dockerfile: Dockerfile
command: npm run start:dev
deploy:
resources:

View File

@@ -2,46 +2,45 @@ version: "3.8"
services:
immich_server:
image: immich-server:1.4.0
image: immich-server:1.7.0
build:
context: ../server
target: production
dockerfile: ../server/Dockerfile
command: npm run start:prod
dockerfile: Dockerfile
entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3000"
volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules
env_file:
- .env
environment:
- NODE_ENV=production
depends_on:
- redis
- database
networks:
- immich_network
restart: unless-stopped
immich_microservices:
image: immich-microservices:1.4.0
image: immich-microservices:1.7.0
build:
context: ../microservices
target: production
dockerfile: ../microservices/Dockerfile
command: npm run start:prod
dockerfile: Dockerfile
entrypoint: ["/bin/sh", "./entrypoint.sh"]
expose:
- "3001"
volumes:
- ../microservices:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules
env_file:
- .env
environment:
- NODE_ENV=production
depends_on:
- database
networks:
- immich_network
restart: unless-stopped
redis:
container_name: immich_redis
@@ -81,28 +80,7 @@ services:
depends_on:
- immich_server
# immich_tf_fastapi:
# container_name: immich_tf_fastapi
# image: tensor_flow_fastapi:1.0.0
# restart: always
# command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload
# build:
# context: ../machine_learning
# target: cpu
# dockerfile: ../machine_learning/Dockerfile
# volumes:
# - ../machine_learning/app:/code/app
# - ${UPLOAD_LOCATION}:/code/app/upload
# ports:
# - 2285:8000
# expose:
# - "8000"
# depends_on:
# - database
# networks:
# - immich_network
networks:
immich_network:
volumes:
pgdata:
pgdata:

View File

@@ -10,11 +10,22 @@ map $http_upgrade $connection_upgrade {
server {
gzip on;
gzip_min_length 1000;
gunzip on;
client_max_body_size 50000M;
listen 80;
access_log off;
location / {
# Compression
gzip_static on;
gzip_min_length 1000;
gzip_comp_level 2;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;

3
fastlane/README.md Normal file
View File

@@ -0,0 +1,3 @@
This directory exists because of the F-Droid build process. F-Droid is using the same directory structure as Fastlane for the app metadata.
Because F-Droid expects the metadata to be located in the root of the repository we need to have this symlink.

1
fastlane/metadata Symbolic link
View File

@@ -0,0 +1 @@
../mobile/android/fastlane/metadata

View File

@@ -1,7 +1,4 @@
##################################
# DEVELOPMENT
##################################
FROM node:16-bullseye-slim AS development
FROM node:16-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive
@@ -17,27 +14,3 @@ RUN npm install
COPY . .
RUN npm run build
#################################
# PRODUCTION
#################################
FROM node:16-bullseye-slim AS production
ARG DEBIAN_FRONTEND=noninteractive
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN apt-get update
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
RUN npm install --only=production
COPY . .
COPY --from=development /usr/src/app/dist ./dist
CMD ["node", "dist/main"]

View File

@@ -0,0 +1,2 @@
# npm run typeorm migration:run
npm run build && npm run start:prod

View File

@@ -39,6 +39,7 @@ export class ImageClassifierService {
}
}
tf.dispose(decodedImage);
return tags;
}
} catch (e) {

View File

@@ -1,10 +1,25 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3001);
await app.listen(3001, () => {
if (process.env.NODE_ENV == 'development') {
Logger.log(
'Running Immich Microservices in DEVELOPMENT environment',
'IMMICH MICROSERVICES',
);
}
if (process.env.NODE_ENV == 'production') {
Logger.log(
'Running Immich Microservices in PRODUCTION environment',
'IMMICH MICROSERVICES',
);
}
});
}
bootstrap();

View File

@@ -29,6 +29,7 @@ export class ObjectDetectionService {
}
}
tf.dispose(decodedImage);
return [...tags];
}
} catch (e) {

View File

@@ -51,7 +51,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "app.alextran.immich"
minSdkVersion 20
minSdkVersion 21
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
@@ -81,4 +81,5 @@ flutter {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.android.support:multidex:1.0.3'
}

View File

@@ -20,4 +20,7 @@
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
</manifest>

View File

@@ -0,0 +1,2 @@
* Accepting webp file format
* Fixed backup stop when an asset is of wrong file type. The app will now skip that asset and try its best to perform the backup operation on the rest of the assets.

View File

@@ -0,0 +1,7 @@
* New features
- Share album. Users can now create albums to share with existing people on the network.
- Owner can delete the album.
- Owner can invite the additional users to the album.
- Shared users and the owner can add additional assets to the album.
* In the asset viewer, the user can swipe up to see detailed information and swip down to dismiss.
* Several UI enhancements.

View File

@@ -0,0 +1 @@
* Added curated locations and objects on the search page

View File

@@ -0,0 +1,2 @@
* User can now download assets to local device
* Increased the font size for curated image thumbnail information on the seach page

View File

@@ -0,0 +1 @@
* Added inline font, remove google-font dependency in pubspec.

View File

@@ -0,0 +1,21 @@
This is a client app for the self-hostable Immich Server (which can be found with the app's source repo). You will need to run/manage the server on your own in order to use the app.
Once set up, this app can be used as photo and video backup solution directly from your mobile phone.
<b>Features:</b>
* Upload and view assets(videos/images).
* Multi-user supported.
* Quick navigation with drag scroll bar.
* Auto Backup.
* Support HEIC/HEIF Backup.
* Extract and display EXIF info.
* Real-time render from multi-device upload event.
* Image Tagging/Classification based on ImageNet dataset
* Object detection based on COCO SSD.
* Search assets based on tags and exif data (lens, make, model, orientation)
* Upload assets from your local computer/server using <a href='https://www.npmjs.com/package/immich' target='_blank' rel='nofollow'>immich cli tools</a>
* [Optional] Reserve geocoding using Mapbox (Generous free-tier of 100,000 search/month)
* Show asset's location information on map (OpenStreetMap).
* Show curated places on the search page
* Show curated objects on the search page

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1 @@
This is a client app for the self-hostable Immich Server

View File

@@ -0,0 +1 @@
Immich

View File

@@ -4,3 +4,4 @@ distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
distributionSha256Sum=0080de8491f0918e4f529a6db6820fa0b9e818ee2386117f4394f95feb1d5583

Binary file not shown.

Binary file not shown.

BIN
mobile/fonts/WorkSans.ttf Normal file

Binary file not shown.

View File

@@ -13,7 +13,7 @@ PODS:
- Flutter
- path_provider_ios (0.0.1):
- Flutter
- photo_manager (1.0.0):
- photo_manager (2.0.0):
- Flutter
- FlutterMacOS
- SAMKeychain (1.5.3)
@@ -70,7 +70,7 @@ SPEC CHECKSUMS:
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5
photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196

View File

@@ -1,66 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Immich</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>2</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSLocationAlwaysUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Light</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>io.flutter.embedded_views_preview</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
</plist>
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Immich</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>2</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
</dict>
<key>NSLocationAlwaysUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Light</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>io.flutter.embedded_views_preview</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
<false />
</dict>
</plist>

View File

@@ -19,11 +19,11 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.4.0"
version_number: "1.7.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,
)
increment_build_number({
build_number: 0
})
build_app(scheme: "Runner",
workspace: "Runner.xcworkspace",
xcargs: "-allowProvisioningUpdates")

View File

@@ -0,0 +1,3 @@
import 'package:flutter/material.dart';
const immichBackgroundColor = Color(0xFFf6f8fe);

View File

@@ -2,21 +2,20 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/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/views/immich_loading_overlay.dart';
import 'constants/hive_box.dart';
import 'package:google_fonts/google_fonts.dart';
void main() async {
await Hive.initFlutter();
await Hive.openBox(userInfoBox);
// Hive.registerAdapter(ImmichBackUpAssetAdapter());
// Hive.deleteBoxFromDisk(hiveImmichBox);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
@@ -88,26 +87,33 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Immich',
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.light,
primarySwatch: Colors.indigo,
textTheme: GoogleFonts.workSansTextTheme(
Theme.of(context).textTheme.apply(fontSizeFactor: 1.0),
),
scaffoldBackgroundColor: const Color(0xFFf6f8fe),
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white,
foregroundColor: Colors.indigo,
elevation: 1,
centerTitle: true,
systemOverlayStyle: SystemUiOverlayStyle.dark,
),
home: Stack(
children: [
MaterialApp.router(
title: 'Immich',
debugShowCheckedModeBanner: false,
theme: ThemeData(
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,
),
),
routeInformationParser: _immichRouter.defaultRouteParser(),
routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]),
),
const ImmichLoadingOverlay(),
],
),
routeInformationParser: _immichRouter.defaultRouteParser(),
routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]),
);
}
}

View File

@@ -1,28 +1,34 @@
import 'dart:convert';
enum DownloadAssetStatus { idle, loading, success, error }
class ImageViewerPageState {
final bool isBottomSheetEnable;
// enum
final DownloadAssetStatus downloadAssetStatus;
ImageViewerPageState({
required this.isBottomSheetEnable,
required this.downloadAssetStatus,
});
ImageViewerPageState copyWith({
bool? isBottomSheetEnable,
DownloadAssetStatus? downloadAssetStatus,
}) {
return ImageViewerPageState(
isBottomSheetEnable: isBottomSheetEnable ?? this.isBottomSheetEnable,
downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus,
);
}
Map<String, dynamic> toMap() {
return {
'isBottomSheetEnable': isBottomSheetEnable,
};
final result = <String, dynamic>{};
result.addAll({'downloadAssetStatus': downloadAssetStatus.index});
return result;
}
factory ImageViewerPageState.fromMap(Map<String, dynamic> map) {
return ImageViewerPageState(
isBottomSheetEnable: map['isBottomSheetEnable'] ?? false,
downloadAssetStatus: DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0],
);
}
@@ -31,15 +37,15 @@ class ImageViewerPageState {
factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source));
@override
String toString() => 'ImageViewerPageState(isBottomSheetEnable: $isBottomSheetEnable)';
String toString() => 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImageViewerPageState && other.isBottomSheetEnable == isBottomSheetEnable;
return other is ImageViewerPageState && other.downloadAssetStatus == downloadAssetStatus;
}
@override
int get hashCode => isBottomSheetEnable.hashCode;
int get hashCode => downloadAssetStatus.hashCode;
}

View File

@@ -0,0 +1,6 @@
class RequestDownloadAssetInfo {
final String assetId;
final String deviceId;
RequestDownloadAssetInfo(this.assetId, this.deviceId);
}

View File

@@ -1,21 +1,43 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
class ImageViewerPageStateNotifier extends StateNotifier<ImageViewerPageState> {
ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false));
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
final ImageViewerService _imageViewerService = ImageViewerService();
void toggleBottomSheet() {
bool isBottomSheetEnable = state.isBottomSheetEnable;
ImageViewerStateNotifier() : super(ImageViewerPageState(downloadAssetStatus: DownloadAssetStatus.idle));
if (isBottomSheetEnable) {
state.copyWith(isBottomSheetEnable: false);
void downloadAsset(ImmichAsset asset, BuildContext context) async {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset);
if (isSuccess) {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success);
ImmichToast.show(
context: context,
msg: "Download Success",
toastType: ToastType.success,
gravity: ToastGravity.BOTTOM,
);
} else {
state.copyWith(isBottomSheetEnable: true);
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error);
ImmichToast.show(
context: context,
msg: "Download Error",
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle);
}
}
final homePageStateProvider = StateNotifierProvider<ImageViewerPageStateNotifier, ImageViewerPageState>(
((ref) => ImageViewerPageStateNotifier()));
final imageViewerStateProvider =
StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(((ref) => ImageViewerStateNotifier()));

View File

@@ -0,0 +1,50 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:path/path.dart' as p;
import 'package:http/http.dart' as http;
import 'package:photo_manager/photo_manager.dart';
import 'package:path_provider/path_provider.dart';
class ImageViewerService {
Future<bool> downloadAssetToDevice(ImmichAsset asset) async {
try {
String fileName = p.basename(asset.originalPath);
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
Uri filePath =
Uri.parse("$savedEndpoint/asset/download?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false");
var res = await http.get(
filePath,
headers: {"Authorization": "Bearer ${Hive.box(userInfoBox).get(accessTokenKey)}"},
);
final AssetEntity? entity;
if (asset.type == 'IMAGE') {
entity = await PhotoManager.editor.saveImage(
res.bodyBytes,
title: p.basename(asset.originalPath),
);
} else {
final tempDir = await getTemporaryDirectory();
File tempFile = await File('${tempDir.path}/$fileName').create();
tempFile.writeAsBytesSync(res.bodyBytes);
entity = await PhotoManager.editor.saveVideo(tempFile, title: fileName);
}
if (entity != null) {
return true;
}
} catch (e) {
debugPrint("Error saving file $e");
return false;
}
return false;
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
class DownloadLoadingIndicator extends StatelessWidget {
const DownloadLoadingIndicator({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 60,
width: 60,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(10),
),
child: const SpinKitDancingSquare(
color: Colors.white,
size: 30.0,
),
);
}
}

View File

@@ -1,14 +1,19 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
const TopControlAppBar({Key? key, required this.asset, required this.onMoreInfoPressed}) : super(key: key);
class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
const TopControlAppBar(
{Key? key, required this.asset, required this.onMoreInfoPressed, required this.onDownloadPressed})
: super(key: key);
final ImmichAsset asset;
final Function onMoreInfoPressed;
final Function onDownloadPressed;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
double iconSize = 18.0;
return AppBar(
@@ -29,7 +34,7 @@ class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
print("download");
onDownloadPressed();
},
icon: const Icon(Icons.cloud_download_rounded),
),

View File

@@ -1,9 +1,14 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
@@ -25,12 +30,25 @@ class ImageViewerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus;
var box = Hive.box(userInfoBox);
getAssetExif() async {
assetDetail = await _assetService.getAssetById(asset.id);
}
showInfo() {
showModalBottomSheet(
backgroundColor: Colors.black,
barrierColor: Colors.transparent,
isScrollControlled: false,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
},
);
}
useEffect(() {
getAssetExif();
return null;
@@ -40,66 +58,76 @@ class ImageViewerPage extends HookConsumerWidget {
backgroundColor: Colors.black,
appBar: TopControlAppBar(
asset: asset,
onMoreInfoPressed: () {
showModalBottomSheet(
backgroundColor: Colors.black,
barrierColor: Colors.transparent,
isScrollControlled: false,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
});
onMoreInfoPressed: showInfo,
onDownloadPressed: () {
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
},
),
body: SafeArea(
child: Center(
child: Hero(
tag: heroTag,
child: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
errorWidget: (context, url, error) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Wrap(
spacing: 32,
runSpacing: 32,
alignment: WrapAlignment.center,
children: [
const Text(
"Failed To Render Image - Possibly Corrupted Data",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.white),
),
SingleChildScrollView(
child: Text(
error.toString(),
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
body: SwipeDetector(
onSwipeDown: (_) {
AutoRouter.of(context).pop();
},
onSwipeUp: (_) {
showInfo();
},
child: SafeArea(
child: Stack(
children: [
Center(
child: Hero(
tag: heroTag,
child: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
errorWidget: (context, url, error) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Wrap(
spacing: 32,
runSpacing: 32,
alignment: WrapAlignment.center,
children: [
const Text(
"Failed To Render Image - Possibly Corrupted Data",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.white),
),
SingleChildScrollView(
child: Text(
error.toString(),
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
),
],
),
),
],
placeholder: (context, url) {
return CachedNetworkImage(
cacheKey: thumbnailUrl,
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
placeholderFadeInDuration: const Duration(milliseconds: 0),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) => Icon(
Icons.error,
color: Colors.grey[300],
),
);
},
),
),
),
placeholder: (context, url) {
return CachedNetworkImage(
cacheKey: thumbnailUrl,
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
placeholderFadeInDuration: const Duration(milliseconds: 0),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) => Icon(
Icons.error,
color: Colors.grey[300],
),
);
},
),
if (downloadAssetStatus == DownloadAssetStatus.loading)
const Center(
child: DownloadLoadingIndicator(),
),
],
),
),
),

View File

@@ -0,0 +1,159 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_swipe_detector/flutter_swipe_detector.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:chewie/chewie.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:video_player/video_player.dart';
// ignore: must_be_immutable
class VideoViewerPage extends HookConsumerWidget {
final String videoUrl;
final ImmichAsset asset;
ImmichAssetWithExif? assetDetail;
final AssetService _assetService = AssetService();
VideoViewerPage({Key? key, required this.videoUrl, required this.asset}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus;
String jwtToken = Hive.box(userInfoBox).get(accessTokenKey);
void showInfo() {
showModalBottomSheet(
backgroundColor: Colors.black,
barrierColor: Colors.transparent,
isScrollControlled: false,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
},
);
}
getAssetExif() async {
assetDetail = await _assetService.getAssetById(asset.id);
}
useEffect(() {
getAssetExif();
return null;
}, []);
return Scaffold(
backgroundColor: Colors.black,
appBar: TopControlAppBar(
asset: asset,
onMoreInfoPressed: () {
showInfo();
},
onDownloadPressed: () {
ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
},
),
body: SwipeDetector(
onSwipeDown: (_) {
AutoRouter.of(context).pop();
},
onSwipeUp: (_) {
showInfo();
},
child: SafeArea(
child: Stack(
children: [
VideoThumbnailPlayer(
url: videoUrl,
jwtToken: jwtToken,
),
if (downloadAssetStatus == DownloadAssetStatus.loading)
const Center(
child: DownloadLoadingIndicator(),
),
],
),
),
),
);
}
}
class VideoThumbnailPlayer extends StatefulWidget {
final String url;
final String? jwtToken;
const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken}) : super(key: key);
@override
State<VideoThumbnailPlayer> createState() => _VideoThumbnailPlayerState();
}
class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
late VideoPlayerController videoPlayerController;
ChewieController? chewieController;
@override
void initState() {
super.initState();
initializePlayer();
}
Future<void> initializePlayer() async {
try {
videoPlayerController =
VideoPlayerController.network(widget.url, httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"});
await videoPlayerController.initialize();
_createChewieController();
setState(() {});
} catch (e) {
debugPrint("ERROR initialize video player");
}
}
_createChewieController() {
chewieController = ChewieController(
showOptions: true,
showControlsOnInitialize: false,
videoPlayerController: videoPlayerController,
autoPlay: true,
autoInitialize: false,
);
}
@override
void dispose() {
super.dispose();
videoPlayerController.pause();
videoPlayerController.dispose();
chewieController?.dispose();
}
@override
Widget build(BuildContext context) {
return chewieController != null && chewieController!.videoPlayerController.value.isInitialized
? SizedBox(
child: Chewie(
controller: chewieController!,
),
)
: const SizedBox(
width: 75,
height: 75,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
);
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
class DeleteDialog extends ConsumerWidget {

View File

@@ -11,40 +11,44 @@ class ImageGrid extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverGrid(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 5.0, mainAxisSpacing: 5),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
var assetType = assetGroup[index].type;
return GestureDetector(
onTap: () {},
child: Stack(
children: [
ThumbnailImage(asset: assetGroup[index]),
assetType == 'IMAGE'
? Container()
: Positioned(
top: 5,
right: 5,
child: Row(
children: [
Text(
assetGroup[index].duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
onTap: () {},
child: Stack(
children: [
ThumbnailImage(asset: assetGroup[index]),
assetType == 'IMAGE'
? Container()
: Positioned(
top: 5,
right: 5,
child: Row(
children: [
Text(
assetGroup[index].duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
],
),
)
],
));
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
)
],
),
);
},
childCount: assetGroup.length,
),

View File

@@ -1,7 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:badges/badges.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -30,7 +29,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
floating: true,
pinned: false,
snap: false,
backgroundColor: Colors.grey[200],
// backgroundColor: Colors.grey[200],
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
leading: Builder(
builder: (BuildContext context) {
@@ -41,7 +40,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
child: IconButton(
splashRadius: 25,
icon: const Icon(
Icons.account_circle_rounded,
Icons.face_outlined,
size: 30,
),
onPressed: () {
@@ -79,12 +78,11 @@ class ImmichSliverAppBar extends ConsumerWidget {
),
title: Text(
'IMMICH',
style: GoogleFonts.snowburstOne(
textStyle: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 22,
color: Theme.of(context).primaryColor,
),
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 22,
color: Theme.of(context).primaryColor,
),
),
actions: [
@@ -121,8 +119,12 @@ class ImmichSliverAppBar extends ConsumerWidget {
),
child: const Icon(Icons.backup_rounded)),
tooltip: 'Backup Controller',
onPressed: () {
AutoRouter.of(context).push(const BackupControllerRoute());
onPressed: () async {
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
if (onPop != null && onPop == true) {
onPopBack!();
}
},
),
_backupState.backupProgress == BackUpProgressEnum.inProgress

View File

@@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.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/home/providers/asset.provider.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';
@@ -79,7 +79,7 @@ class ProfileDrawer extends HookConsumerWidget {
),
title: const Text(
"Sign Out",
style: TextStyle(color: Colors.black54, fontSize: 14),
style: TextStyle(color: Colors.black54, fontSize: 14, fontWeight: FontWeight.bold),
),
onTap: () async {
bool res = await ref.read(authenticationProvider.notifier).logout();

View File

@@ -65,8 +65,8 @@ class ThumbnailImage extends HookConsumerWidget {
} else {
AutoRouter.of(context).push(
VideoViewerRoute(
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
),
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
asset: asset),
);
}
}

View File

@@ -10,7 +10,7 @@ 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/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:sliver_tools/sliver_tools.dart';
@@ -33,6 +33,10 @@ class HomePage extends HookConsumerWidget {
return null;
}, []);
void reloadAllAsset() {
ref.read(assetProvider.notifier).getAllAsset();
}
Widget _buildBody() {
if (assetGroupByDateTime.isNotEmpty) {
int? lastMonth;
@@ -86,7 +90,9 @@ class HomePage extends HookConsumerWidget {
child: null,
),
)
: const ImmichSliverAppBar(),
: ImmichSliverAppBar(
onPopBack: reloadAllAsset,
),
duration: const Duration(milliseconds: 350),
),
..._imageGridGroup

View File

@@ -1,9 +1,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
@@ -33,9 +32,12 @@ class LoginForm extends HookConsumerWidget {
),
Text(
'IMMICH',
style: GoogleFonts.snowburstOne(
textStyle:
TextStyle(fontWeight: FontWeight.bold, fontSize: 48, color: Theme.of(context).primaryColor)),
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 48,
color: Theme.of(context).primaryColor,
),
),
EmailInput(controller: usernameController),
PasswordInput(controller: passwordController),
@@ -117,20 +119,21 @@ class LoginButton extends ConsumerWidget {
// This will remove current cache asset state of previous user login.
ref.watch(assetProvider.notifier).clearAllAsset();
var isAuthenicated = await ref
var isAuthenticated = await ref
.read(authenticationProvider.notifier)
.login(emailController.text, passwordController.text, serverEndpointController.text);
if (isAuthenicated) {
if (isAuthenticated) {
// Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup();
// AutoRouter.of(context).pushNamed("/home-page");
AutoRouter.of(context).pushNamed("/tab-controller-page");
} else {
ImmichToast.show(
context: context,
msg: "Error logging you in, check server url, email and password!",
toastType: ToastType.error);
context: context,
msg: "Error logging you in, check server url, email and password!",
toastType: ToastType.error,
);
}
},
child: const Text("Login"));

View File

@@ -0,0 +1,67 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
class ThumbnailWithInfo extends StatelessWidget {
const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap})
: super(key: key);
final String textInfo;
final String imageUrl;
final Function onTap;
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
return GestureDetector(
onTap: () {
onTap();
},
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: SizedBox(
width: MediaQuery.of(context).size.width / 2,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
Container(
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Colors.black26,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: CachedNetworkImage(
width: 250,
height: 250,
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
),
),
),
Positioned(
bottom: 8,
left: 10,
child: SizedBox(
width: MediaQuery.of(context).size.width / 3,
child: Text(
textInfo.capitalizeFirstLetter(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
],
),
),
),
);
}
}

View File

@@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
@@ -10,7 +10,9 @@ import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/search_bar.dart';
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
// ignore: must_be_immutable
@@ -23,8 +25,10 @@ class SearchPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
AsyncValue<List<CuratedLocation>> curatedLocation = ref.watch(getCuratedLocationProvider);
AsyncValue<List<CuratedObject>> curatedObjects = ref.watch(getCuratedObjectProvider);
AsyncValue<List<CuratedLocation>> curatedLocation =
ref.watch(getCuratedLocationProvider);
AsyncValue<List<CuratedObject>> curatedObjects =
ref.watch(getCuratedObjectProvider);
useEffect(() {
searchFocusNode = FocusNode();
@@ -40,12 +44,15 @@ class SearchPage extends HookConsumerWidget {
_buildPlaces() {
return curatedLocation.when(
loading: () => const CircularProgressIndicator(),
loading: () => const SizedBox(
height: 200,
child: Center(child: ImmichLoadingIndicator()),
),
error: (err, stack) => Text('Error: $err'),
data: (curatedLocations) {
return curatedLocations.isNotEmpty
? SizedBox(
height: MediaQuery.of(context).size.width / 3,
height: MediaQuery.of(context).size.width / 2,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
@@ -59,14 +66,15 @@ class SearchPage extends HookConsumerWidget {
imageUrl: thumbnailRequestUrl,
textInfo: locationInfo.city,
onTap: () {
AutoRouter.of(context).push(SearchResultRoute(searchTerm: locationInfo.city));
AutoRouter.of(context).push(
SearchResultRoute(searchTerm: locationInfo.city));
},
);
}),
),
)
: SizedBox(
height: MediaQuery.of(context).size.width / 3,
height: MediaQuery.of(context).size.width / 2,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
@@ -87,12 +95,15 @@ class SearchPage extends HookConsumerWidget {
_buildThings() {
return curatedObjects.when(
loading: () => const CircularProgressIndicator(),
loading: () => const SizedBox(
height: 200,
child: Center(child: ImmichLoadingIndicator()),
),
error: (err, stack) => Text('Error: $err'),
data: (objects) {
return objects.isNotEmpty
? SizedBox(
height: MediaQuery.of(context).size.width / 3,
height: MediaQuery.of(context).size.width / 2,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
@@ -106,15 +117,16 @@ class SearchPage extends HookConsumerWidget {
imageUrl: thumbnailRequestUrl,
textInfo: curatedObjectInfo.object,
onTap: () {
AutoRouter.of(context)
.push(SearchResultRoute(searchTerm: curatedObjectInfo.object.capitalizeFirstLetter()));
AutoRouter.of(context).push(SearchResultRoute(
searchTerm: curatedObjectInfo.object
.capitalizeFirstLetter()));
},
);
}),
),
)
: SizedBox(
height: MediaQuery.of(context).size.width / 3,
height: MediaQuery.of(context).size.width / 2,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
@@ -165,73 +177,12 @@ class SearchPage extends HookConsumerWidget {
_buildThings()
],
),
isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
isSearchEnabled
? SearchSuggestionList(onSubmitted: _onSearchSubmitted)
: Container(),
],
),
),
);
}
}
class ThumbnailWithInfo extends StatelessWidget {
const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap})
: super(key: key);
final String textInfo;
final String imageUrl;
final Function onTap;
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
return GestureDetector(
onTap: () {
onTap();
},
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: SizedBox(
width: MediaQuery.of(context).size.width / 3,
height: MediaQuery.of(context).size.width / 3,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
Container(
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Colors.black26,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: CachedNetworkImage(
width: 150,
height: 150,
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
),
),
),
Positioned(
bottom: 8,
left: 10,
child: SizedBox(
width: MediaQuery.of(context).size.width / 3,
child: Text(
textInfo.capitalizeFirstLetter(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
],
),
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
@@ -107,7 +108,10 @@ class SearchResultPage extends HookConsumerWidget {
}
if (searchResultPageState.isLoading) {
return const CircularProgressIndicator.adaptive();
return Center(
child: SpinKitDancingSquare(
color: Theme.of(context).primaryColor,
));
}
if (searchResultPageState.isSuccess) {

View File

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

View File

@@ -0,0 +1,103 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class AssetSelectionState {
final Set<String> selectedMonths;
final Set<ImmichAsset> selectedNewAssetsForAlbum;
final Set<ImmichAsset> selectedAdditionalAssetsForAlbum;
final Set<ImmichAsset> selectedAssetsInAlbumViewer;
final bool isMultiselectEnable;
/// Indicate the asset selection page is navigated from existing album
final bool isAlbumExist;
AssetSelectionState({
required this.selectedMonths,
required this.selectedNewAssetsForAlbum,
required this.selectedAdditionalAssetsForAlbum,
required this.selectedAssetsInAlbumViewer,
required this.isMultiselectEnable,
required this.isAlbumExist,
});
AssetSelectionState copyWith({
Set<String>? selectedMonths,
Set<ImmichAsset>? selectedNewAssetsForAlbum,
Set<ImmichAsset>? selectedAdditionalAssetsForAlbum,
Set<ImmichAsset>? selectedAssetsInAlbumViewer,
bool? isMultiselectEnable,
bool? isAlbumExist,
}) {
return AssetSelectionState(
selectedMonths: selectedMonths ?? this.selectedMonths,
selectedNewAssetsForAlbum: selectedNewAssetsForAlbum ?? this.selectedNewAssetsForAlbum,
selectedAdditionalAssetsForAlbum: selectedAdditionalAssetsForAlbum ?? this.selectedAdditionalAssetsForAlbum,
selectedAssetsInAlbumViewer: selectedAssetsInAlbumViewer ?? this.selectedAssetsInAlbumViewer,
isMultiselectEnable: isMultiselectEnable ?? this.isMultiselectEnable,
isAlbumExist: isAlbumExist ?? this.isAlbumExist,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'selectedMonths': selectedMonths.toList()});
result.addAll({'selectedNewAssetsForAlbum': selectedNewAssetsForAlbum.map((x) => x.toMap()).toList()});
result
.addAll({'selectedAdditionalAssetsForAlbum': selectedAdditionalAssetsForAlbum.map((x) => x.toMap()).toList()});
result.addAll({'selectedAssetsInAlbumViewer': selectedAssetsInAlbumViewer.map((x) => x.toMap()).toList()});
result.addAll({'isMultiselectEnable': isMultiselectEnable});
result.addAll({'isAlbumExist': isAlbumExist});
return result;
}
factory AssetSelectionState.fromMap(Map<String, dynamic> map) {
return AssetSelectionState(
selectedMonths: Set<String>.from(map['selectedMonths']),
selectedNewAssetsForAlbum:
Set<ImmichAsset>.from(map['selectedNewAssetsForAlbum']?.map((x) => ImmichAsset.fromMap(x))),
selectedAdditionalAssetsForAlbum:
Set<ImmichAsset>.from(map['selectedAdditionalAssetsForAlbum']?.map((x) => ImmichAsset.fromMap(x))),
selectedAssetsInAlbumViewer:
Set<ImmichAsset>.from(map['selectedAssetsInAlbumViewer']?.map((x) => ImmichAsset.fromMap(x))),
isMultiselectEnable: map['isMultiselectEnable'] ?? false,
isAlbumExist: map['isAlbumExist'] ?? false,
);
}
String toJson() => json.encode(toMap());
factory AssetSelectionState.fromJson(String source) => AssetSelectionState.fromMap(json.decode(source));
@override
String toString() {
return 'AssetSelectionState(selectedMonths: $selectedMonths, selectedNewAssetsForAlbum: $selectedNewAssetsForAlbum, selectedAdditionalAssetsForAlbum: $selectedAdditionalAssetsForAlbum, selectedAssetsInAlbumViewer: $selectedAssetsInAlbumViewer, isMultiselectEnable: $isMultiselectEnable, isAlbumExist: $isAlbumExist)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final setEquals = const DeepCollectionEquality().equals;
return other is AssetSelectionState &&
setEquals(other.selectedMonths, selectedMonths) &&
setEquals(other.selectedNewAssetsForAlbum, selectedNewAssetsForAlbum) &&
setEquals(other.selectedAdditionalAssetsForAlbum, selectedAdditionalAssetsForAlbum) &&
setEquals(other.selectedAssetsInAlbumViewer, selectedAssetsInAlbumViewer) &&
other.isMultiselectEnable == isMultiselectEnable &&
other.isAlbumExist == isAlbumExist;
}
@override
int get hashCode {
return selectedMonths.hashCode ^
selectedNewAssetsForAlbum.hashCode ^
selectedAdditionalAssetsForAlbum.hashCode ^
selectedAssetsInAlbumViewer.hashCode ^
isMultiselectEnable.hashCode ^
isAlbumExist.hashCode;
}
}

View File

@@ -0,0 +1,113 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:immich_mobile/modules/sharing/models/shared_asset.model.dart';
import 'package:immich_mobile/modules/sharing/models/shared_user.model.dart';
class SharedAlbum {
final String id;
final String ownerId;
final String albumName;
final String createdAt;
final String? albumThumbnailAssetId;
final List<SharedUsers> sharedUsers;
final List<SharedAssets>? sharedAssets;
SharedAlbum({
required this.id,
required this.ownerId,
required this.albumName,
required this.createdAt,
required this.albumThumbnailAssetId,
required this.sharedUsers,
this.sharedAssets,
});
SharedAlbum copyWith({
String? id,
String? ownerId,
String? albumName,
String? createdAt,
String? albumThumbnailAssetId,
List<SharedUsers>? sharedUsers,
List<SharedAssets>? sharedAssets,
}) {
return SharedAlbum(
id: id ?? this.id,
ownerId: ownerId ?? this.ownerId,
albumName: albumName ?? this.albumName,
createdAt: createdAt ?? this.createdAt,
albumThumbnailAssetId: albumThumbnailAssetId ?? this.albumThumbnailAssetId,
sharedUsers: sharedUsers ?? this.sharedUsers,
sharedAssets: sharedAssets ?? this.sharedAssets,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'id': id});
result.addAll({'ownerId': ownerId});
result.addAll({'albumName': albumName});
result.addAll({'createdAt': createdAt});
if (albumThumbnailAssetId != null) {
result.addAll({'albumThumbnailAssetId': albumThumbnailAssetId});
}
result.addAll({'sharedUsers': sharedUsers.map((x) => x.toMap()).toList()});
if (sharedAssets != null) {
result.addAll({'sharedAssets': sharedAssets!.map((x) => x.toMap()).toList()});
}
return result;
}
factory SharedAlbum.fromMap(Map<String, dynamic> map) {
return SharedAlbum(
id: map['id'] ?? '',
ownerId: map['ownerId'] ?? '',
albumName: map['albumName'] ?? '',
createdAt: map['createdAt'] ?? '',
albumThumbnailAssetId: map['albumThumbnailAssetId'],
sharedUsers: List<SharedUsers>.from(map['sharedUsers']?.map((x) => SharedUsers.fromMap(x))),
sharedAssets: map['sharedAssets'] != null
? List<SharedAssets>.from(map['sharedAssets']?.map((x) => SharedAssets.fromMap(x)))
: null,
);
}
String toJson() => json.encode(toMap());
factory SharedAlbum.fromJson(String source) => SharedAlbum.fromMap(json.decode(source));
@override
String toString() {
return 'SharedAlbum(id: $id, ownerId: $ownerId, albumName: $albumName, createdAt: $createdAt, albumThumbnailAssetId: $albumThumbnailAssetId, sharedUsers: $sharedUsers, sharedAssets: $sharedAssets)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return other is SharedAlbum &&
other.id == id &&
other.ownerId == ownerId &&
other.albumName == albumName &&
other.createdAt == createdAt &&
other.albumThumbnailAssetId == albumThumbnailAssetId &&
listEquals(other.sharedUsers, sharedUsers) &&
listEquals(other.sharedAssets, sharedAssets);
}
@override
int get hashCode {
return id.hashCode ^
ownerId.hashCode ^
albumName.hashCode ^
createdAt.hashCode ^
albumThumbnailAssetId.hashCode ^
sharedUsers.hashCode ^
sharedAssets.hashCode;
}
}

View File

@@ -0,0 +1,50 @@
import 'dart:convert';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class SharedAssets {
final ImmichAsset assetInfo;
SharedAssets({
required this.assetInfo,
});
SharedAssets copyWith({
ImmichAsset? assetInfo,
}) {
return SharedAssets(
assetInfo: assetInfo ?? this.assetInfo,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'assetInfo': assetInfo.toMap()});
return result;
}
factory SharedAssets.fromMap(Map<String, dynamic> map) {
return SharedAssets(
assetInfo: ImmichAsset.fromMap(map['assetInfo']),
);
}
String toJson() => json.encode(toMap());
factory SharedAssets.fromJson(String source) => SharedAssets.fromMap(json.decode(source));
@override
String toString() => 'SharedAssets(assetInfo: $assetInfo)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is SharedAssets && other.assetInfo == assetInfo;
}
@override
int get hashCode => assetInfo.hashCode;
}

View File

@@ -0,0 +1,76 @@
import 'dart:convert';
import 'package:immich_mobile/shared/models/user_info.model.dart';
class SharedUsers {
final int id;
final String albumId;
final String sharedUserId;
final UserInfo userInfo;
SharedUsers({
required this.id,
required this.albumId,
required this.sharedUserId,
required this.userInfo,
});
SharedUsers copyWith({
int? id,
String? albumId,
String? sharedUserId,
UserInfo? userInfo,
}) {
return SharedUsers(
id: id ?? this.id,
albumId: albumId ?? this.albumId,
sharedUserId: sharedUserId ?? this.sharedUserId,
userInfo: userInfo ?? this.userInfo,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'id': id});
result.addAll({'albumId': albumId});
result.addAll({'sharedUserId': sharedUserId});
result.addAll({'userInfo': userInfo.toMap()});
return result;
}
factory SharedUsers.fromMap(Map<String, dynamic> map) {
return SharedUsers(
id: map['id']?.toInt() ?? 0,
albumId: map['albumId'] ?? '',
sharedUserId: map['sharedUserId'] ?? '',
userInfo: UserInfo.fromMap(map['userInfo']),
);
}
String toJson() => json.encode(toMap());
factory SharedUsers.fromJson(String source) => SharedUsers.fromMap(json.decode(source));
@override
String toString() {
return 'SharedUsers(id: $id, albumId: $albumId, sharedUserId: $sharedUserId, userInfo: $userInfo)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is SharedUsers &&
other.id == id &&
other.albumId == albumId &&
other.sharedUserId == sharedUserId &&
other.userInfo == userInfo;
}
@override
int get hashCode {
return id.hashCode ^ albumId.hashCode ^ sharedUserId.hashCode ^ userInfo.hashCode;
}
}

View File

@@ -0,0 +1,15 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
class AlbumTitleNotifier extends StateNotifier<String> {
AlbumTitleNotifier() : super("");
setAlbumTitle(String title) {
state = title;
}
clearAlbumTitle() {
state = "";
}
}
final albumTitleProvider = StateNotifierProvider<AlbumTitleNotifier, String>((ref) => AlbumTitleNotifier());

View File

@@ -0,0 +1,113 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/sharing/models/asset_selection_state.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
AssetSelectionNotifier()
: super(AssetSelectionState(
selectedNewAssetsForAlbum: {},
selectedMonths: {},
selectedAdditionalAssetsForAlbum: {},
selectedAssetsInAlbumViewer: {},
isAlbumExist: false,
isMultiselectEnable: false,
));
void setIsAlbumExist(bool isAlbumExist) {
state = state.copyWith(isAlbumExist: isAlbumExist);
}
void removeAssetsInMonth(String removedMonth, List<ImmichAsset> assetsInMonth) {
Set<ImmichAsset> currentAssetList = state.selectedNewAssetsForAlbum;
Set<String> currentMonthList = state.selectedMonths;
currentMonthList.removeWhere((selectedMonth) => selectedMonth == removedMonth);
for (ImmichAsset asset in assetsInMonth) {
currentAssetList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(selectedNewAssetsForAlbum: currentAssetList, selectedMonths: currentMonthList);
}
void addAdditionalAssets(List<ImmichAsset> assets) {
state = state.copyWith(
selectedAdditionalAssetsForAlbum: {...state.selectedAdditionalAssetsForAlbum, ...assets},
);
}
void addAllAssetsInMonth(String month, List<ImmichAsset> assetsInMonth) {
state = state.copyWith(
selectedMonths: {...state.selectedMonths, month},
selectedNewAssetsForAlbum: {...state.selectedNewAssetsForAlbum, ...assetsInMonth},
);
}
void addNewAssets(List<ImmichAsset> assets) {
state = state.copyWith(
selectedNewAssetsForAlbum: {...state.selectedNewAssetsForAlbum, ...assets},
);
}
void removeSelectedNewAssets(List<ImmichAsset> assets) {
Set<ImmichAsset> currentList = state.selectedNewAssetsForAlbum;
for (ImmichAsset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(selectedNewAssetsForAlbum: currentList);
}
void removeSelectedAdditionalAssets(List<ImmichAsset> assets) {
Set<ImmichAsset> currentList = state.selectedAdditionalAssetsForAlbum;
for (ImmichAsset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(selectedAdditionalAssetsForAlbum: currentList);
}
void removeAll() {
state = state.copyWith(
selectedNewAssetsForAlbum: {},
selectedMonths: {},
selectedAdditionalAssetsForAlbum: {},
selectedAssetsInAlbumViewer: {},
isAlbumExist: false,
);
}
void enableMultiselection() {
state = state.copyWith(isMultiselectEnable: true);
}
void disableMultiselection() {
state = state.copyWith(
isMultiselectEnable: false,
selectedAssetsInAlbumViewer: {},
);
}
void addAssetsInAlbumViewer(List<ImmichAsset> assets) {
state = state.copyWith(
selectedAssetsInAlbumViewer: {...state.selectedAssetsInAlbumViewer, ...assets},
);
}
void removeAssetsInAlbumViewer(List<ImmichAsset> assets) {
Set<ImmichAsset> currentList = state.selectedAssetsInAlbumViewer;
for (ImmichAsset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(selectedAssetsInAlbumViewer: currentList);
}
}
final assetSelectionProvider = StateNotifierProvider<AssetSelectionNotifier, AssetSelectionState>((ref) {
return AssetSelectionNotifier();
});

View File

@@ -0,0 +1,57 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
class SharedAlbumNotifier extends StateNotifier<List<SharedAlbum>> {
SharedAlbumNotifier() : super([]);
final SharedAlbumService _sharedAlbumService = SharedAlbumService();
getAllSharedAlbums() async {
List<SharedAlbum> sharedAlbums = await _sharedAlbumService.getAllSharedAlbum();
state = sharedAlbums;
}
Future<bool> deleteAlbum(String albumId) async {
var res = await _sharedAlbumService.deleteAlbum(albumId);
if (res) {
state = state.where((album) => album.id != albumId).toList();
return true;
} else {
return false;
}
}
Future<bool> leaveAlbum(String albumId) async {
var res = await _sharedAlbumService.leaveAlbum(albumId);
if (res) {
state = state.where((album) => album.id != albumId).toList();
return true;
} else {
return false;
}
}
Future<bool> removeAssetFromAlbum(String albumId, List<String> assetIds) async {
var res = await _sharedAlbumService.removeAssetFromAlbum(albumId, assetIds);
if (res) {
return true;
} else {
return false;
}
}
}
final sharedAlbumProvider = StateNotifierProvider<SharedAlbumNotifier, List<SharedAlbum>>((ref) {
return SharedAlbumNotifier();
});
final sharedAlbumDetailProvider = FutureProvider.autoDispose.family<SharedAlbum, String>((ref, albumId) async {
final SharedAlbumService _sharedAlbumService = SharedAlbumService();
return await _sharedAlbumService.getAlbumDetail(albumId);
});

View File

@@ -0,0 +1,9 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/user_info.model.dart';
import 'package:immich_mobile/shared/services/user.service.dart';
final suggestedSharedUsersProvider = FutureProvider.autoDispose<List<UserInfo>>((ref) async {
UserService userService = UserService();
return await userService.getAllUsersInfo();
});

View File

@@ -0,0 +1,141 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
class SharedAlbumService {
final NetworkService _networkService = NetworkService();
Future<List<SharedAlbum>> getAllSharedAlbum() async {
try {
var res = await _networkService.getRequest(url: 'shared/allSharedAlbums');
List<dynamic> decodedData = jsonDecode(res.toString());
List<SharedAlbum> result = List.from(decodedData.map((e) => SharedAlbum.fromMap(e)));
return result;
} catch (e) {
debugPrint("Error getAllSharedAlbum ${e.toString()}");
}
return [];
}
Future<bool> createSharedAlbum(String albumName, Set<ImmichAsset> assets, List<String> sharedUserIds) async {
try {
var res = await _networkService.postRequest(url: 'shared/createAlbum', data: {
"albumName": albumName,
"sharedWithUserIds": sharedUserIds,
"assetIds": assets.map((asset) => asset.id).toList(),
});
if (res == null) {
return false;
}
return true;
} catch (e) {
debugPrint("Error createSharedAlbum ${e.toString()}");
return false;
}
}
Future<SharedAlbum> getAlbumDetail(String albumId) async {
try {
var res = await _networkService.getRequest(url: 'shared/$albumId');
dynamic decodedData = jsonDecode(res.toString());
SharedAlbum result = SharedAlbum.fromMap(decodedData);
return result;
} catch (e) {
throw Exception('Error getAllSharedAlbum ${e.toString()}');
}
}
Future<bool> addAdditionalAssetToAlbum(Set<ImmichAsset> assets, String albumId) async {
try {
var res = await _networkService.postRequest(url: 'shared/addAssets', data: {
"albumId": albumId,
"assetIds": assets.map((asset) => asset.id).toList(),
});
if (res == null) {
return false;
}
return true;
} catch (e) {
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
return false;
}
}
Future<bool> addAdditionalUserToAlbum(List<String> sharedUserIds, String albumId) async {
try {
var res = await _networkService.postRequest(url: 'shared/addUsers', data: {
"albumId": albumId,
"sharedUserIds": sharedUserIds,
});
if (res == null) {
return false;
}
return true;
} catch (e) {
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
return false;
}
}
Future<bool> deleteAlbum(String albumId) async {
try {
Response res = await _networkService.deleteRequest(url: 'shared/$albumId');
if (res.statusCode != 200) {
return false;
}
return true;
} catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}");
return false;
}
}
Future<bool> leaveAlbum(String albumId) async {
try {
Response res = await _networkService.deleteRequest(url: 'shared/leaveAlbum/$albumId');
if (res.statusCode != 200) {
return false;
}
return true;
} catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}");
return false;
}
}
Future<bool> removeAssetFromAlbum(String albumId, List<String> assetIds) async {
try {
Response res = await _networkService.deleteRequest(url: 'shared/removeAssets/', data: {
"albumId": albumId,
"assetIds": assetIds,
});
if (res.statusCode != 200) {
return false;
}
return true;
} catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}");
return false;
}
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class AlbumActionOutlinedButton extends StatelessWidget {
final VoidCallback? onPressed;
final String labelText;
final IconData iconData;
const AlbumActionOutlinedButton({Key? key, this.onPressed, required this.labelText, required this.iconData})
: super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: OutlinedButton.icon(
style: ButtonStyle(
padding: MaterialStateProperty.all<EdgeInsets>(const EdgeInsets.symmetric(vertical: 0, horizontal: 10)),
shape: MaterialStateProperty.resolveWith<OutlinedBorder>(
(_) => RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
),
),
side: MaterialStateProperty.resolveWith<BorderSide>(
(_) => const BorderSide(width: 1, color: Color.fromARGB(255, 158, 158, 158)),
),
),
icon: Icon(iconData, size: 15),
label: Text(
labelText,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black87),
),
onPressed: onPressed,
),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/sharing/providers/album_title.provider.dart';
class AlbumTitleTextField extends ConsumerWidget {
const AlbumTitleTextField({
Key? key,
required this.isAlbumTitleEmpty,
required this.albumTitleTextFieldFocusNode,
required this.albumTitleController,
required this.isAlbumTitleTextFieldFocus,
}) : super(key: key);
final ValueNotifier<bool> isAlbumTitleEmpty;
final FocusNode albumTitleTextFieldFocusNode;
final TextEditingController albumTitleController;
final ValueNotifier<bool> isAlbumTitleTextFieldFocus;
@override
Widget build(BuildContext context, WidgetRef ref) {
return TextField(
onChanged: (v) {
if (v.isEmpty) {
isAlbumTitleEmpty.value = true;
} else {
isAlbumTitleEmpty.value = false;
}
ref.watch(albumTitleProvider.notifier).setAlbumTitle(v);
},
focusNode: albumTitleTextFieldFocusNode,
style: TextStyle(fontSize: 28, color: Colors.grey[700], fontWeight: FontWeight.bold),
controller: albumTitleController,
onTap: () {
isAlbumTitleTextFieldFocus.value = true;
if (albumTitleController.text == 'Untitled') {
albumTitleController.clear();
}
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
suffixIcon: !isAlbumTitleEmpty.value && isAlbumTitleTextFieldFocus.value
? IconButton(
onPressed: () {
albumTitleController.clear();
isAlbumTitleEmpty.value = true;
},
icon: const Icon(Icons.cancel_rounded),
splashRadius: 10,
)
: null,
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.transparent),
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.transparent),
borderRadius: BorderRadius.circular(10),
),
hintText: 'Add a title',
focusColor: Colors.grey[300],
fillColor: Colors.grey[200],
filled: isAlbumTitleTextFieldFocus.value,
),
);
}
}

View File

@@ -0,0 +1,181 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.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/sharing/models/shared_album.model.dart';
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
const AlbumViewerAppbar({
Key? key,
required AsyncValue<SharedAlbum> albumInfo,
required this.userId,
required this.albumId,
}) : _albumInfo = albumInfo,
super(key: key);
final AsyncValue<SharedAlbum> _albumInfo;
final String userId;
final String albumId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable;
final selectedAssetsInAlbum = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
void _onDeleteAlbumPressed(String albumId) async {
ImmichLoadingOverlayController.appLoader.show();
bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(albumId);
if (isSuccess) {
AutoRouter.of(context).navigate(const TabControllerRoute(children: [SharingRoute()]));
} else {
ImmichToast.show(
context: context,
msg: "Failed to delete album",
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
ImmichLoadingOverlayController.appLoader.hide();
}
void _onLeaveAlbumPressed(String albumId) async {
ImmichLoadingOverlayController.appLoader.show();
bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(albumId);
if (isSuccess) {
AutoRouter.of(context).navigate(const TabControllerRoute(children: [SharingRoute()]));
} else {
Navigator.pop(context);
ImmichToast.show(
context: context,
msg: "Failed to leave album",
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
ImmichLoadingOverlayController.appLoader.hide();
}
void _onRemoveFromAlbumPressed(String albumId) async {
ImmichLoadingOverlayController.appLoader.show();
bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum(
albumId,
selectedAssetsInAlbum.map((a) => a.id).toList(),
);
if (isSuccess) {
Navigator.pop(context);
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
ref.refresh(sharedAlbumDetailProvider(albumId));
} else {
Navigator.pop(context);
ImmichToast.show(
context: context,
msg: "There are problems in removing assets from album",
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
ImmichLoadingOverlayController.appLoader.hide();
}
_buildBottomSheetActionButton() {
if (isMultiSelectionEnable) {
if (_albumInfo.asData?.value.ownerId == userId) {
return ListTile(
leading: const Icon(Icons.delete_sweep_rounded),
title: const Text(
'Remove from album',
style: TextStyle(fontWeight: FontWeight.bold),
),
onTap: () => _onRemoveFromAlbumPressed(albumId),
);
} else {
return Container();
}
} else {
if (_albumInfo.asData?.value.ownerId == userId) {
return ListTile(
leading: const Icon(Icons.delete_forever_rounded),
title: const Text(
'Delete album',
style: TextStyle(fontWeight: FontWeight.bold),
),
onTap: () => _onDeleteAlbumPressed(albumId),
);
} else {
return ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: const Text(
'Leave album',
style: TextStyle(fontWeight: FontWeight.bold),
),
onTap: () => _onLeaveAlbumPressed(albumId),
);
}
}
}
void _buildBottomSheet() {
showModalBottomSheet(
backgroundColor: immichBackgroundColor,
isScrollControlled: false,
context: context,
builder: (context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildBottomSheetActionButton(),
],
);
},
);
}
_buildLeadingButton() {
if (isMultiSelectionEnable) {
return IconButton(
onPressed: () => ref.watch(assetSelectionProvider.notifier).disableMultiselection(),
icon: const Icon(Icons.close_rounded),
splashRadius: 25,
);
} else {
return IconButton(
onPressed: () async => await AutoRouter.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
splashRadius: 25,
);
}
}
return AppBar(
elevation: 0,
leading: _buildLeadingButton(),
title: isMultiSelectionEnable ? Text(selectedAssetsInAlbum.length.toString()) : Container(),
centerTitle: false,
actions: [
IconButton(
splashRadius: 25,
onPressed: _buildBottomSheet,
icon: const Icon(Icons.more_horiz_rounded),
),
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View File

@@ -0,0 +1,181 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/routing/router.dart';
class AlbumViewerThumbnail extends HookConsumerWidget {
final ImmichAsset asset;
const AlbumViewerThumbnail({Key? key, required this.asset}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1);
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
var deviceId = ref.watch(authenticationProvider).deviceId;
final selectedAssetsInAlbumViewer = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
final isMultiSelectionEnable = ref.watch(assetSelectionProvider).isMultiselectEnable;
_viewAsset() {
if (asset.type == 'IMAGE') {
AutoRouter.of(context).push(
ImageViewerRoute(
imageUrl:
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
heroTag: asset.id,
thumbnailUrl: thumbnailRequestUrl,
asset: asset,
),
);
} else {
AutoRouter.of(context).push(
VideoViewerRoute(
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
asset: asset),
);
}
}
BoxBorder drawBorderColor() {
if (selectedAssetsInAlbumViewer.contains(asset)) {
return Border.all(
color: Theme.of(context).primaryColorLight,
width: 10,
);
} else {
return const Border();
}
}
_enableMultiSelection() {
ref.watch(assetSelectionProvider.notifier).enableMultiselection();
ref.watch(assetSelectionProvider.notifier).addAssetsInAlbumViewer([asset]);
}
_disableMultiSelection() {
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
}
_buildVideoLabel() {
if (asset.type == 'IMAGE') {
return Container();
} else {
return Positioned(
top: 5,
right: 5,
child: Row(
children: [
Text(
asset.duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
);
}
}
_buildAssetStoreLocationIcon() {
return Positioned(
right: 10,
bottom: 5,
child: Icon(
(deviceId != asset.deviceId) ? Icons.cloud_done_outlined : Icons.photo_library_rounded,
color: Colors.white,
size: 18,
),
);
}
_buildAssetSelectionIcon() {
bool isSelected = selectedAssetsInAlbumViewer.contains(asset);
if (isMultiSelectionEnable) {
return Positioned(
left: 10,
top: 5,
child: isSelected
? Icon(
Icons.check_circle_rounded,
color: Theme.of(context).primaryColor,
)
: const Icon(
Icons.check_circle_outline_rounded,
color: Colors.white,
),
);
} else {
return Container();
}
}
_buildThumbnailImage() {
return Container(
decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage(
cacheKey: "${asset.id}-${cacheKey.value}",
width: 300,
height: 300,
memCacheHeight: 200,
fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) {
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
),
);
}
_handleSelectionGesture() {
if (selectedAssetsInAlbumViewer.contains(asset)) {
ref.watch(assetSelectionProvider.notifier).removeAssetsInAlbumViewer([asset]);
if (selectedAssetsInAlbumViewer.isEmpty) {
_disableMultiSelection();
}
} else {
ref.watch(assetSelectionProvider.notifier).addAssetsInAlbumViewer([asset]);
}
}
return GestureDetector(
onTap: isMultiSelectionEnable ? _handleSelectionGesture : _viewAsset,
onLongPress: _enableMultiSelection,
child: Hero(
tag: asset.id,
child: Stack(
children: [
_buildThumbnailImage(),
_buildAssetStoreLocationIcon(),
_buildVideoLabel(),
_buildAssetSelectionIcon(),
],
),
),
);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/sharing/ui/selection_thumbnail_image.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class AssetGridByMonth extends HookConsumerWidget {
final List<ImmichAsset> assetGroup;
const AssetGridByMonth({Key? key, required this.assetGroup}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return SelectionThumbnailImage(asset: assetGroup[index]);
},
childCount: assetGroup.length,
),
);
}
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class MonthGroupTitle extends HookConsumerWidget {
final String month;
final List<ImmichAsset> assetGroup;
const MonthGroupTitle({Key? key, required this.month, required this.assetGroup}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedDateGroup = ref.watch(assetSelectionProvider).selectedMonths;
final selectedAssets = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
_handleTitleIconClick() {
HapticFeedback.heavyImpact();
if (isAlbumExist) {
if (selectedDateGroup.contains(month)) {
ref.watch(assetSelectionProvider.notifier).removeAssetsInMonth(month, []);
ref.watch(assetSelectionProvider.notifier).removeSelectedAdditionalAssets(assetGroup);
} else {
ref.watch(assetSelectionProvider.notifier).addAllAssetsInMonth(month, []);
// Deep clone assetGroup
var assetGroupWithNewItems = [...assetGroup];
for (var selectedAsset in selectedAssets) {
assetGroupWithNewItems.removeWhere((a) => a.id == selectedAsset.id);
}
ref.watch(assetSelectionProvider.notifier).addAdditionalAssets(assetGroupWithNewItems);
}
} else {
if (selectedDateGroup.contains(month)) {
ref.watch(assetSelectionProvider.notifier).removeAssetsInMonth(month, assetGroup);
} else {
ref.watch(assetSelectionProvider.notifier).addAllAssetsInMonth(month, assetGroup);
}
}
}
_getSimplifiedMonth() {
var monthAndYear = month.split(',');
var yearText = monthAndYear[1].trim();
var monthText = monthAndYear[0].trim();
var currentYear = DateTime.now().year.toString();
if (yearText == currentYear) {
return monthText;
} else {
return month;
}
}
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 14.0, right: 8.0),
child: Row(
children: [
GestureDetector(
onTap: _handleTitleIconClick,
child: selectedDateGroup.contains(month)
? Icon(
Icons.check_circle_rounded,
color: Theme.of(context).primaryColor,
)
: const Icon(
Icons.circle_outlined,
color: Colors.grey,
),
),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
_getSimplifiedMonth(),
style: TextStyle(
fontSize: 24,
color: Theme.of(context).primaryColor,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,149 @@
import 'package:cached_network_image/cached_network_image.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:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/sharing/providers/asset_selection.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class SelectionThumbnailImage extends HookConsumerWidget {
final ImmichAsset asset;
const SelectionThumbnailImage({Key? key, required this.asset}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1);
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
var selectedAsset = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
var newAssetsForAlbum = ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
Widget _buildSelectionIcon(ImmichAsset asset) {
if (selectedAsset.contains(asset) && !isAlbumExist) {
return Icon(
Icons.check_circle,
color: Theme.of(context).primaryColor,
);
} else if (selectedAsset.contains(asset) && isAlbumExist) {
return const Icon(
Icons.check_circle,
color: Color.fromARGB(255, 233, 233, 233),
);
} else if (newAssetsForAlbum.contains(asset) && isAlbumExist) {
return Icon(
Icons.check_circle,
color: Theme.of(context).primaryColor,
);
} else {
return const Icon(
Icons.circle_outlined,
color: Colors.white,
);
}
}
BoxBorder drawBorderColor() {
if (selectedAsset.contains(asset) && !isAlbumExist) {
return Border.all(
color: Theme.of(context).primaryColorLight,
width: 10,
);
} else if (selectedAsset.contains(asset) && isAlbumExist) {
return Border.all(
color: const Color.fromARGB(255, 190, 190, 190),
width: 10,
);
} else if (newAssetsForAlbum.contains(asset) && isAlbumExist) {
return Border.all(
color: Theme.of(context).primaryColorLight,
width: 10,
);
}
return const Border();
}
return GestureDetector(
onTap: () {
if (isAlbumExist) {
// Operation for existing album
if (!selectedAsset.contains(asset)) {
if (newAssetsForAlbum.contains(asset)) {
ref.watch(assetSelectionProvider.notifier).removeSelectedAdditionalAssets([asset]);
} else {
ref.watch(assetSelectionProvider.notifier).addAdditionalAssets([asset]);
}
}
} else {
// Operation for new album
if (selectedAsset.contains(asset)) {
ref.watch(assetSelectionProvider.notifier).removeSelectedNewAssets([asset]);
} else {
ref.watch(assetSelectionProvider.notifier).addNewAssets([asset]);
}
}
},
child: Hero(
tag: asset.id,
child: Stack(
children: [
Container(
decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage(
cacheKey: "${asset.id}-${cacheKey.value}",
width: 150,
height: 150,
memCacheHeight: asset.type == 'IMAGE' ? 150 : 150,
fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) {
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
),
),
Padding(
padding: const EdgeInsets.all(3.0),
child: Align(
alignment: Alignment.topLeft,
child: _buildSelectionIcon(asset),
),
),
asset.type == 'IMAGE'
? Container()
: Positioned(
bottom: 5,
right: 5,
child: Row(
children: [
Text(
asset.duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
)
],
),
),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:cached_network_image/cached_network_image.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:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class SharedAlbumThumbnailImage extends HookConsumerWidget {
final ImmichAsset asset;
const SharedAlbumThumbnailImage({Key? key, required this.asset}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1);
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
return GestureDetector(
onTap: () {
// debugPrint("View ${asset.id}");
},
child: Hero(
tag: asset.id,
child: Stack(
children: [
CachedNetworkImage(
cacheKey: "${asset.id}-${cacheKey.value}",
width: 500,
height: 500,
memCacheHeight: asset.type == 'IMAGE' ? 500 : 500,
fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) {
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
),
],
),
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/routing/router.dart';
class SharingSliverAppBar extends StatelessWidget {
const SharingSliverAppBar({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SliverAppBar(
centerTitle: true,
floating: false,
pinned: true,
snap: false,
leading: Container(),
// elevation: 0,
title: Text(
'IMMICH',
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 22,
color: Theme.of(context).primaryColor,
),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(50.0),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
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),
),
onPressed: () {
AutoRouter.of(context).push(const CreateSharedAlbumRoute());
},
icon: const Icon(
Icons.photo_album_outlined,
size: 20,
),
label: const Text(
"Create shared album",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
),
),
),
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),
),
onPressed: null,
icon: const Icon(
Icons.swap_horizontal_circle_outlined,
size: 20,
),
label: const Text(
"Share with partner",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
),
),
)
],
),
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More