Initial commit
47
.gitignore
vendored
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
.vscode/
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.packages
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
|
||||||
|
# Web related
|
||||||
|
lib/generated_plugin_registrant.dart
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
10
.metadata
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: 8f89f6505b941329a864fef1527243a72800bf4d
|
||||||
|
channel: beta
|
||||||
|
|
||||||
|
project_type: app
|
12
README.md
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# Photos (for Nextcloud)
|
||||||
|
Photos (for Nextcloud) is a new gallery app for viewing your photos hosted on Nextcloud servers
|
||||||
|
|
||||||
|
[<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Google Play" width="160" />](https://play.google.com/store/apps/details?id=com.nkming.nc_photos)
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Sign-in to multiple servers
|
||||||
|
- EXIF support (JPEG only for now)
|
||||||
|
- Organize photos with albums that are independent of your file hierarchy
|
||||||
|
- and more to come!
|
||||||
|
|
||||||
|
This app does not require any server-side plugins.
|
14
android/.gitignore
vendored
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
|
|
||||||
|
# Remember to never publicly share your keystore.
|
||||||
|
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
|
||||||
|
key.properties
|
||||||
|
|
||||||
|
/app/src/debug
|
||||||
|
/app/src/profile
|
81
android/app/build.gradle
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
def localProperties = new Properties()
|
||||||
|
def localPropertiesFile = rootProject.file('local.properties')
|
||||||
|
if (localPropertiesFile.exists()) {
|
||||||
|
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||||
|
localProperties.load(reader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||||
|
if (flutterRoot == null) {
|
||||||
|
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||||
|
if (flutterVersionCode == null) {
|
||||||
|
flutterVersionCode = '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||||
|
if (flutterVersionName == null) {
|
||||||
|
flutterVersionName = '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||||
|
|
||||||
|
ext.abiCodes = ["armeabi-v7a": 1, "arm64-v8a": 2, "x86_64": 3]
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdkVersion 30
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
main.java.srcDirs += 'src/main/kotlin'
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId "com.nkming.nc_photos"
|
||||||
|
minSdkVersion 19
|
||||||
|
targetSdkVersion 30
|
||||||
|
versionCode flutterVersionCode.toInteger()
|
||||||
|
versionName flutterVersionName
|
||||||
|
multiDexEnabled true
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
// signingConfig signingConfigs.debug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
splits {
|
||||||
|
abi {
|
||||||
|
enable true
|
||||||
|
reset()
|
||||||
|
include "armeabi-v7a", "arm64-v8a", "x86_64"
|
||||||
|
universalApk true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applicationVariants.all { variant ->
|
||||||
|
variant.outputs.each { output ->
|
||||||
|
def baseAbiVersionCode = project.ext.abiCodes.get(output.getFilter(com.android.build.OutputFile.ABI))
|
||||||
|
if (baseAbiVersionCode != null) {
|
||||||
|
output.versionCodeOverride = baseAbiVersionCode + variant.versionCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source '../..'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||||
|
implementation "androidx.multidex:multidex:2.0.1"
|
||||||
|
}
|
2
android/app/proguard-rules.pro
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
# To ensure that retracing stack traces is unambiguous
|
||||||
|
-keepattributes LineNumberTable,SourceFile
|
50
android/app/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
package="com.nkming.nc_photos">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="28"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:name="androidx.multidex.MultiDexApplication"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<!-- Displays an Android View that continues showing the launch screen
|
||||||
|
Drawable until Flutter paints its first frame, then this splash
|
||||||
|
screen fades out. A splash screen is useful to avoid any visual
|
||||||
|
gap between the end of Android's launch screen and the painting of
|
||||||
|
Flutter's first frame. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.SplashScreenDrawable"
|
||||||
|
android:resource="@drawable/launch_background"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
112
android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
package com.nkming.nc_photos
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.annotation.NonNull
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.io.BufferedOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
private const val PERMISSION_REQUEST_CODE = 11011
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity() {
|
||||||
|
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
MediaStoreChannelHandler.CHANNEL)
|
||||||
|
.setMethodCallHandler(MediaStoreChannelHandler(this))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MediaStoreChannelHandler(activity: Activity)
|
||||||
|
: MethodChannel.MethodCallHandler {
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
val CHANNEL = "com.nkming.nc_photos/media_store"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
if (call.method == "saveFileToDownload") {
|
||||||
|
saveFileToDownload(call.argument<String>("fileName")!!,
|
||||||
|
call.argument<ByteArray>("content")!!, result)
|
||||||
|
} else {
|
||||||
|
result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveFileToDownload(fileName: String, content: ByteArray,
|
||||||
|
result: MethodChannel.Result) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
saveFileToDownload29(fileName, content, result)
|
||||||
|
} else {
|
||||||
|
saveFileToDownload0(fileName, content, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
private fun saveFileToDownload29(fileName: String, content: ByteArray,
|
||||||
|
result: MethodChannel.Result) {
|
||||||
|
// Add a media item that other apps shouldn't see until the item is
|
||||||
|
// fully written to the media store.
|
||||||
|
val resolver = _context.applicationContext.contentResolver
|
||||||
|
|
||||||
|
// Find all audio files on the primary external storage device.
|
||||||
|
val collection = MediaStore.Downloads.getContentUri(
|
||||||
|
MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||||
|
val details = ContentValues().apply {
|
||||||
|
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentUri = resolver.insert(collection, details)
|
||||||
|
|
||||||
|
resolver.openFileDescriptor(contentUri!!, "w", null).use { pfd ->
|
||||||
|
// Write data into the pending audio file.
|
||||||
|
BufferedOutputStream(FileOutputStream(pfd!!.fileDescriptor)).use {
|
||||||
|
stream -> stream.write(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveFileToDownload0(fileName: String, content: ByteArray,
|
||||||
|
result: MethodChannel.Result) {
|
||||||
|
if (ContextCompat.checkSelfPermission(_activity,
|
||||||
|
Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
ActivityCompat.requestPermissions(_activity,
|
||||||
|
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
|
||||||
|
PERMISSION_REQUEST_CODE)
|
||||||
|
result.error("permissionError", "Permission not granted", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val path = Environment.getExternalStoragePublicDirectory(
|
||||||
|
Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
var file = File(path, fileName)
|
||||||
|
var count = 1
|
||||||
|
while (file.exists()) {
|
||||||
|
val f = File(fileName)
|
||||||
|
file = File(path, "${f.nameWithoutExtension} ($count).${f.extension}")
|
||||||
|
++count
|
||||||
|
}
|
||||||
|
BufferedOutputStream(FileOutputStream(file)).use {
|
||||||
|
stream -> stream.write(content)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _activity = activity
|
||||||
|
private val _context get() = _activity
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#d3efff" />
|
||||||
|
</shape>
|
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 3.6 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 6.1 KiB |
After Width: | Height: | Size: 4.5 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
Flutter draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
4
android/app/src/main/res/values/strings.xml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Photos</string>
|
||||||
|
</resources>
|
18
android/app/src/main/res/values/styles.xml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
Flutter draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
31
android/build.gradle
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
buildscript {
|
||||||
|
ext.kotlin_version = '1.3.50'
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
jcenter()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:4.1.0'
|
||||||
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
jcenter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.buildDir = '../build'
|
||||||
|
subprojects {
|
||||||
|
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||||
|
}
|
||||||
|
subprojects {
|
||||||
|
project.evaluationDependsOn(':app')
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
3
android/gradle.properties
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
org.gradle.jvmargs=-Xmx1536M
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
6
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
#Fri Jun 23 08:50:38 CEST 2017
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
|
11
android/settings.gradle
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
include ':app'
|
||||||
|
|
||||||
|
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
|
||||||
|
def properties = new Properties()
|
||||||
|
|
||||||
|
assert localPropertiesFile.exists()
|
||||||
|
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
|
||||||
|
|
||||||
|
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
|
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||||
|
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
|
BIN
assets/2.0x/setup_hidden_pref_dir.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
assets/setup_hidden_pref_dir.png
Normal file
After Width: | Height: | Size: 14 KiB |
3
l10n.yaml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
arb-dir: lib/l10n
|
||||||
|
template-arb-file: app_en.arb
|
||||||
|
output-localization-file: app_localizations.dart
|
76
lib/account.dart
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:nc_photos/string_extension.dart';
|
||||||
|
|
||||||
|
/// Details of a remote Nextcloud server account
|
||||||
|
class Account with EquatableMixin {
|
||||||
|
Account(
|
||||||
|
this.scheme,
|
||||||
|
String address,
|
||||||
|
this.username,
|
||||||
|
this.password,
|
||||||
|
List<String> roots,
|
||||||
|
) : this.address = address.trimRightAny("/"),
|
||||||
|
_roots = roots.map((e) => e.trimRightAny("/")).toList() {
|
||||||
|
if (scheme != "http" && scheme != "https") {
|
||||||
|
throw FormatException("scheme is neither http or https");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Account copyWith({
|
||||||
|
String scheme,
|
||||||
|
String address,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
List<String> roots,
|
||||||
|
}) {
|
||||||
|
return Account(
|
||||||
|
scheme ?? this.scheme,
|
||||||
|
address ?? this.address,
|
||||||
|
username ?? this.username,
|
||||||
|
password ?? this.password,
|
||||||
|
roots ?? _roots,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"scheme: '$scheme', "
|
||||||
|
"address: '$address', "
|
||||||
|
"username: '$username', "
|
||||||
|
"password: '${password?.isNotEmpty == true ? (kDebugMode ? password : '***') : null}', "
|
||||||
|
"roots: List {'${roots.join('\', \'')}'}, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
Account.fromJson(Map<String, dynamic> json)
|
||||||
|
: scheme = json["scheme"],
|
||||||
|
address = json["address"],
|
||||||
|
username = json["username"],
|
||||||
|
password = json["password"],
|
||||||
|
_roots = json["roots"].cast<String>();
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"scheme": scheme,
|
||||||
|
"address": address,
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"roots": _roots,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [scheme, address, username, password, _roots];
|
||||||
|
|
||||||
|
List<String> get roots => _roots;
|
||||||
|
|
||||||
|
final String scheme;
|
||||||
|
final String address;
|
||||||
|
final String username;
|
||||||
|
final String password;
|
||||||
|
final List<String> _roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AccountExtension on Account {
|
||||||
|
String get url => "$scheme://$address";
|
||||||
|
}
|
328
lib/api/api.dart
Normal file
|
@ -0,0 +1,328 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
class Response {
|
||||||
|
Response(this.statusCode, this.headers, this.body);
|
||||||
|
|
||||||
|
bool get isGood => _isHttpStatusGood(statusCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return "{"
|
||||||
|
"status: $statusCode, "
|
||||||
|
"headers: ..., "
|
||||||
|
"body: ..., "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final int statusCode;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
|
||||||
|
/// Content of the response body, String if isResponseString == true during
|
||||||
|
/// request, Uint8List otherwise
|
||||||
|
final dynamic body;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Api {
|
||||||
|
Api(this._account);
|
||||||
|
|
||||||
|
_Files files() => _Files(this);
|
||||||
|
|
||||||
|
static String getAuthorizationHeaderValue(Account account) {
|
||||||
|
final auth =
|
||||||
|
base64.encode(utf8.encode("${account.username}:${account.password}"));
|
||||||
|
return "Basic $auth";
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> request(
|
||||||
|
String method,
|
||||||
|
String endpoint, {
|
||||||
|
Map<String, String> header,
|
||||||
|
String body,
|
||||||
|
Uint8List bodyBytes,
|
||||||
|
bool isResponseString = true,
|
||||||
|
}) async {
|
||||||
|
Uri url;
|
||||||
|
if (_account.scheme == "http") {
|
||||||
|
url = Uri.http(_account.address, endpoint);
|
||||||
|
} else {
|
||||||
|
url = Uri.https(_account.address, endpoint);
|
||||||
|
}
|
||||||
|
final req = http.Request(method, url)
|
||||||
|
..headers.addAll({
|
||||||
|
"authorization": getAuthorizationHeaderValue(_account),
|
||||||
|
});
|
||||||
|
if (header != null) {
|
||||||
|
// turn all to lower case, since HTTP headers are case-insensitive, this
|
||||||
|
// smooths our processing (if any)
|
||||||
|
req.headers.addEntries(
|
||||||
|
header.entries.map((e) => MapEntry(e.key.toLowerCase(), e.value)));
|
||||||
|
}
|
||||||
|
if (body != null) {
|
||||||
|
if (!req.headers.containsKey("content-type")) {
|
||||||
|
req.headers["content-type"] = "application/xml";
|
||||||
|
}
|
||||||
|
req.body = body;
|
||||||
|
} else if (bodyBytes != null) {
|
||||||
|
if (!req.headers.containsKey("content-type")) {
|
||||||
|
req.headers["content-type"] = "application/octet-stream";
|
||||||
|
}
|
||||||
|
req.bodyBytes = bodyBytes;
|
||||||
|
}
|
||||||
|
final response =
|
||||||
|
await http.Response.fromStream(await http.Client().send(req));
|
||||||
|
if (!_isHttpStatusGood(response.statusCode)) {
|
||||||
|
_log.severe(
|
||||||
|
"[request] HTTP $method (${response.statusCode}): $endpoint",
|
||||||
|
response.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Response(response.statusCode, response.headers,
|
||||||
|
isResponseString ? response.body : response.bodyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account _account;
|
||||||
|
|
||||||
|
static final _log = Logger("api.api.Api");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isHttpStatusGood(int status) => status ~/ 100 == 2;
|
||||||
|
|
||||||
|
class _Files {
|
||||||
|
_Files(this._api);
|
||||||
|
|
||||||
|
Api _api;
|
||||||
|
|
||||||
|
Future<Response> delete({
|
||||||
|
@required String path,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _api.request("DELETE", path);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[delete] Failed while delete", e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> get({
|
||||||
|
@required String path,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _api.request("GET", path, isResponseString: false);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[get] Failed while get", e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> put({
|
||||||
|
@required String path,
|
||||||
|
String mime = "application/octet-stream",
|
||||||
|
Uint8List content,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _api.request(
|
||||||
|
"PUT",
|
||||||
|
path,
|
||||||
|
header: {
|
||||||
|
"content-type": mime,
|
||||||
|
},
|
||||||
|
bodyBytes: content,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[put] Failed while put", e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> propfind({
|
||||||
|
@required String path,
|
||||||
|
int depth,
|
||||||
|
getlastmodified,
|
||||||
|
getetag,
|
||||||
|
getcontenttype,
|
||||||
|
resourcetype,
|
||||||
|
getcontentlength,
|
||||||
|
id,
|
||||||
|
fileid,
|
||||||
|
favorite,
|
||||||
|
commentsHref,
|
||||||
|
commentsCount,
|
||||||
|
commentsUnread,
|
||||||
|
ownerId,
|
||||||
|
ownerDisplayName,
|
||||||
|
shareTypes,
|
||||||
|
checksums,
|
||||||
|
hasPreview,
|
||||||
|
size,
|
||||||
|
richWorkspace,
|
||||||
|
Map<String, String> customNamespaces,
|
||||||
|
List<String> customProperties,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final bool hasDavNs = (getlastmodified != null ||
|
||||||
|
getetag != null ||
|
||||||
|
getcontenttype != null ||
|
||||||
|
resourcetype != null ||
|
||||||
|
getcontentlength != null);
|
||||||
|
final bool hasOcNs = (id != null ||
|
||||||
|
fileid != null ||
|
||||||
|
favorite != null ||
|
||||||
|
commentsHref != null ||
|
||||||
|
commentsCount != null ||
|
||||||
|
commentsUnread != null ||
|
||||||
|
ownerId != null ||
|
||||||
|
ownerDisplayName != null ||
|
||||||
|
shareTypes != null ||
|
||||||
|
checksums != null ||
|
||||||
|
size != null);
|
||||||
|
final bool hasNcNs = (hasPreview != null || richWorkspace != null);
|
||||||
|
if (!hasDavNs && !hasOcNs && !hasNcNs) {
|
||||||
|
// no body
|
||||||
|
return await _api.request("PROPFIND", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
final namespaces = <String, String>{
|
||||||
|
"DAV:": "d",
|
||||||
|
if (hasOcNs) "http://owncloud.org/ns": "oc",
|
||||||
|
if (hasNcNs) "http://nextcloud.org/ns": "nc",
|
||||||
|
}..addAll(customNamespaces ?? {});
|
||||||
|
final builder = XmlBuilder();
|
||||||
|
builder
|
||||||
|
..processing("xml", "version=\"1.0\"")
|
||||||
|
..element("d:propfind", namespaces: namespaces, nest: () {
|
||||||
|
builder.element("d:prop", nest: () {
|
||||||
|
if (getlastmodified != null) {
|
||||||
|
builder.element("d:getlastmodified");
|
||||||
|
}
|
||||||
|
if (getetag != null) {
|
||||||
|
builder.element("d:getetag");
|
||||||
|
}
|
||||||
|
if (getcontenttype != null) {
|
||||||
|
builder.element("d:getcontenttype");
|
||||||
|
}
|
||||||
|
if (resourcetype != null) {
|
||||||
|
builder.element("d:resourcetype");
|
||||||
|
}
|
||||||
|
if (getcontentlength != null) {
|
||||||
|
builder.element("d:getcontentlength");
|
||||||
|
}
|
||||||
|
if (id != null) {
|
||||||
|
builder.element("oc:id");
|
||||||
|
}
|
||||||
|
if (fileid != null) {
|
||||||
|
builder.element("oc:fileid");
|
||||||
|
}
|
||||||
|
if (favorite != null) {
|
||||||
|
builder.element("oc:favorite");
|
||||||
|
}
|
||||||
|
if (commentsHref != null) {
|
||||||
|
builder.element("oc:comments-href");
|
||||||
|
}
|
||||||
|
if (commentsCount != null) {
|
||||||
|
builder.element("oc:comments-count");
|
||||||
|
}
|
||||||
|
if (commentsUnread != null) {
|
||||||
|
builder.element("oc:comments-unread");
|
||||||
|
}
|
||||||
|
if (ownerId != null) {
|
||||||
|
builder.element("oc:owner-id");
|
||||||
|
}
|
||||||
|
if (ownerDisplayName != null) {
|
||||||
|
builder.element("oc:owner-display-name");
|
||||||
|
}
|
||||||
|
if (shareTypes != null) {
|
||||||
|
builder.element("oc:share-types");
|
||||||
|
}
|
||||||
|
if (checksums != null) {
|
||||||
|
builder.element("oc:checksums");
|
||||||
|
}
|
||||||
|
if (size != null) {
|
||||||
|
builder.element("oc:size");
|
||||||
|
}
|
||||||
|
if (hasPreview != null) {
|
||||||
|
builder.element("nc:has-preview");
|
||||||
|
}
|
||||||
|
if (richWorkspace != null) {
|
||||||
|
builder.element("nc:rich-workspace");
|
||||||
|
}
|
||||||
|
for (final p in customProperties) {
|
||||||
|
builder.element(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return await _api.request("PROPFIND", path,
|
||||||
|
body: builder.buildDocument().toXmlString());
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[propfind] Failed while propfind", e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set or remove custom properties
|
||||||
|
///
|
||||||
|
/// [namespaces] should be specified in the format {"URI": "prefix"}, eg,
|
||||||
|
/// {"DAV:": "d"}
|
||||||
|
Future<Response> proppatch({
|
||||||
|
@required String path,
|
||||||
|
Map<String, String> namespaces,
|
||||||
|
Map<String, dynamic> set,
|
||||||
|
List<String> remove,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final ns = <String, String>{
|
||||||
|
"DAV:": "d",
|
||||||
|
}..addAll(namespaces ?? {});
|
||||||
|
final builder = XmlBuilder();
|
||||||
|
builder
|
||||||
|
..processing("xml", "version=\"1.0\"")
|
||||||
|
..element("d:propertyupdate", namespaces: ns, nest: () {
|
||||||
|
if (set?.isNotEmpty == true) {
|
||||||
|
builder.element("d:set", nest: () {
|
||||||
|
builder.element("d:prop", nest: () {
|
||||||
|
for (final e in set.entries) {
|
||||||
|
builder.element("${e.key}", nest: () {
|
||||||
|
builder.text("${e.value}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (remove?.isNotEmpty == true) {
|
||||||
|
builder.element("d:remove", nest: () {
|
||||||
|
builder.element("d:prop", nest: () {
|
||||||
|
for (final e in remove) {
|
||||||
|
builder.element("$e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return await _api.request("PROPPATCH", path,
|
||||||
|
body: builder.buildDocument().toXmlString());
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[proppatch] Failed while proppatch", e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder can be created by sending a MKCOL request to the folder
|
||||||
|
Future<Response> mkcol({
|
||||||
|
@required String path,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _api.request("MKCOL", path);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[mkcol] Failed while get", e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _log = Logger("api.api._Files");
|
||||||
|
}
|
81
lib/api/api_util.dart
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
/// Helper functions working with remote Nextcloud server
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
|
||||||
|
/// Return the preview image URL for [file]. See [getFilePreviewUrlRelative]
|
||||||
|
String getFilePreviewUrl(
|
||||||
|
Account account,
|
||||||
|
File file, {
|
||||||
|
@required int width,
|
||||||
|
@required int height,
|
||||||
|
String mode,
|
||||||
|
bool a,
|
||||||
|
}) {
|
||||||
|
return "${account.url}/"
|
||||||
|
"${getFilePreviewUrlRelative(file, width: width, height: height, mode: mode, a: a)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the relative preview image URL for [file]. If [a] == true, the
|
||||||
|
/// preview will maintain the original aspect ratio, otherwise it will be
|
||||||
|
/// cropped
|
||||||
|
String getFilePreviewUrlRelative(
|
||||||
|
File file, {
|
||||||
|
@required int width,
|
||||||
|
@required int height,
|
||||||
|
String mode,
|
||||||
|
bool a,
|
||||||
|
}) {
|
||||||
|
final filePath = Uri.encodeQueryComponent(file.strippedPath);
|
||||||
|
var url = "core/preview.png?file=$filePath&x=$width&y=$height";
|
||||||
|
if (mode != null) {
|
||||||
|
url = "$url&mode=$mode";
|
||||||
|
}
|
||||||
|
if (a != null) {
|
||||||
|
url = "$url&a=${a ? 1 : 0}";
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getFileUrl(Account account, File file) {
|
||||||
|
return "${account.url}/${getFileUrlRelative(file)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
String getFileUrlRelative(File file) {
|
||||||
|
return file.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getWebdavRootUrlRelative(Account account) =>
|
||||||
|
"remote.php/dav/files/${account.username}";
|
||||||
|
|
||||||
|
/// Query the app password for [account]
|
||||||
|
Future<String> exchangePassword(Account account) async {
|
||||||
|
final response = await Api(account).request(
|
||||||
|
"GET",
|
||||||
|
"ocs/v2.php/core/getapppassword",
|
||||||
|
header: {
|
||||||
|
"OCS-APIRequest": "true",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.isGood) {
|
||||||
|
final appPwdRegex = RegExp(r"<apppassword>(.*)</apppassword>");
|
||||||
|
final appPwdMatch = appPwdRegex.firstMatch(response.body);
|
||||||
|
return appPwdMatch.group(1);
|
||||||
|
} else if (response.statusCode == 403) {
|
||||||
|
// If the client is authenticated with an app password a 403 will be
|
||||||
|
// returned
|
||||||
|
_log.info("[exchangePassword] Already an app password");
|
||||||
|
return account.password;
|
||||||
|
} else {
|
||||||
|
_log.severe(
|
||||||
|
"[exchangePassword] Failed while requesting app password: $response");
|
||||||
|
throw HttpException(
|
||||||
|
"Failed communicating with server: ${response.statusCode}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _log = Logger("api.api_util");
|
43
lib/app_db.dart
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:idb_shim/idb.dart';
|
||||||
|
import 'package:nc_photos/mobile/platform.dart'
|
||||||
|
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
|
class AppDb {
|
||||||
|
static const dbName = "app.db";
|
||||||
|
static const fileStoreName = "files";
|
||||||
|
static const albumStoreName = "albums";
|
||||||
|
|
||||||
|
/// Run [fn] with an opened database instance
|
||||||
|
///
|
||||||
|
/// This function guarantees that:
|
||||||
|
/// 1) Database is always closed after [fn] exits, even with an error
|
||||||
|
/// 2) Only at most 1 database instance being opened at any time
|
||||||
|
static Future<T> use<T>(FutureOr<T> Function(Database) fn) async {
|
||||||
|
// make sure only one client is opening the db
|
||||||
|
return await _lock.synchronized(() async {
|
||||||
|
final db = await _open();
|
||||||
|
try {
|
||||||
|
return await fn(db);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the database
|
||||||
|
static Future<Database> _open() async {
|
||||||
|
final dbFactory = platform.MyApp.getDbFactory();
|
||||||
|
return dbFactory.open(dbName, version: 1, onUpgradeNeeded: (event) {
|
||||||
|
final db = event.database;
|
||||||
|
if (event.oldVersion < 1) {
|
||||||
|
db.createObjectStore(fileStoreName);
|
||||||
|
db.createObjectStore(albumStoreName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _lock = Lock(reentrant: true);
|
||||||
|
}
|
84
lib/bloc/app_password_exchange.dart
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||||
|
|
||||||
|
abstract class AppPasswordExchangeBlocEvent {
|
||||||
|
const AppPasswordExchangeBlocEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppPasswordExchangeBlocConnect extends AppPasswordExchangeBlocEvent {
|
||||||
|
const AppPasswordExchangeBlocConnect(this.account);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"account: $account, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class AppPasswordExchangeBlocState {
|
||||||
|
const AppPasswordExchangeBlocState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppPasswordExchangeBlocInit extends AppPasswordExchangeBlocState {
|
||||||
|
const AppPasswordExchangeBlocInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppPasswordExchangeBlocSuccess extends AppPasswordExchangeBlocState {
|
||||||
|
const AppPasswordExchangeBlocSuccess(this.password);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"password: ${kDebugMode ? password : '***'}, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final String password;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppPasswordExchangeBlocFailure extends AppPasswordExchangeBlocState {
|
||||||
|
const AppPasswordExchangeBlocFailure(this.exception);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"exception: $exception, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppPasswordExchangeBloc
|
||||||
|
extends Bloc<AppPasswordExchangeBlocEvent, AppPasswordExchangeBlocState> {
|
||||||
|
AppPasswordExchangeBloc() : super(AppPasswordExchangeBlocInit());
|
||||||
|
|
||||||
|
@override
|
||||||
|
mapEventToState(AppPasswordExchangeBlocEvent event) async* {
|
||||||
|
_log.info("[mapEventToState] $event");
|
||||||
|
if (event is AppPasswordExchangeBlocConnect) {
|
||||||
|
yield* _exchangePassword(event.account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<AppPasswordExchangeBlocState> _exchangePassword(
|
||||||
|
Account account) async* {
|
||||||
|
try {
|
||||||
|
final appPwd = await api_util.exchangePassword(account);
|
||||||
|
yield AppPasswordExchangeBlocSuccess(appPwd);
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.severe("[_exchangePassword] Failed while exchanging password", e,
|
||||||
|
stacktrace);
|
||||||
|
yield AppPasswordExchangeBlocFailure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _log =
|
||||||
|
Logger("bloc.app_password_exchange.AppPasswordExchangeBloc");
|
||||||
|
}
|
222
lib/bloc/list_album.dart
Normal file
|
@ -0,0 +1,222 @@
|
||||||
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/album.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/event/event.dart';
|
||||||
|
import 'package:nc_photos/use_case/list_album.dart';
|
||||||
|
|
||||||
|
abstract class ListAlbumBlocEvent {
|
||||||
|
const ListAlbumBlocEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListAlbumBlocQuery extends ListAlbumBlocEvent {
|
||||||
|
const ListAlbumBlocQuery(this.account);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"account: $account, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An external event has happened and may affect the state of this bloc
|
||||||
|
class _ListAlbumBlocExternalEvent extends ListAlbumBlocEvent {
|
||||||
|
const _ListAlbumBlocExternalEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class ListAlbumBlocState {
|
||||||
|
const ListAlbumBlocState(this.account, this.albums);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"account: $account, "
|
||||||
|
"albums: List {length: ${albums.length}}, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final List<Album> albums;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListAlbumBlocInit extends ListAlbumBlocState {
|
||||||
|
const ListAlbumBlocInit() : super(null, const []);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListAlbumBlocLoading extends ListAlbumBlocState {
|
||||||
|
const ListAlbumBlocLoading(Account account, List<Album> albums)
|
||||||
|
: super(account, albums);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListAlbumBlocSuccess extends ListAlbumBlocState {
|
||||||
|
const ListAlbumBlocSuccess(Account account, List<Album> albums)
|
||||||
|
: super(account, albums);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListAlbumBlocFailure extends ListAlbumBlocState {
|
||||||
|
const ListAlbumBlocFailure(
|
||||||
|
Account account, List<Album> albums, this.exception)
|
||||||
|
: super(account, albums);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"super: ${super.toString()}, "
|
||||||
|
"exception: $exception, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final dynamic exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state of this bloc is inconsistent. This typically means that the data
|
||||||
|
/// may have been changed externally
|
||||||
|
class ListAlbumBlocInconsistent extends ListAlbumBlocState {
|
||||||
|
const ListAlbumBlocInconsistent(Account account, List<Album> albums)
|
||||||
|
: super(account, albums);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListAlbumBloc extends Bloc<ListAlbumBlocEvent, ListAlbumBlocState> {
|
||||||
|
ListAlbumBloc() : super(ListAlbumBlocInit()) {
|
||||||
|
_fileMetadataUpdatedListener =
|
||||||
|
AppEventListener<FileMetadataUpdatedEvent>(_onFileMetadataUpdatedEvent);
|
||||||
|
_albumUpdatedListener =
|
||||||
|
AppEventListener<AlbumUpdatedEvent>(_onAlbumUpdatedEvent);
|
||||||
|
_fileRemovedListener =
|
||||||
|
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
|
||||||
|
_albumCreatedListener =
|
||||||
|
AppEventListener<AlbumCreatedEvent>(_onAlbumCreatedEvent);
|
||||||
|
_fileMetadataUpdatedListener.begin();
|
||||||
|
_albumUpdatedListener.begin();
|
||||||
|
_fileRemovedListener.begin();
|
||||||
|
_albumCreatedListener.begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
mapEventToState(ListAlbumBlocEvent event) async* {
|
||||||
|
_log.info("[mapEventToState] $event");
|
||||||
|
if (event is ListAlbumBlocQuery) {
|
||||||
|
yield* _onEventQuery(event);
|
||||||
|
} else if (event is _ListAlbumBlocExternalEvent) {
|
||||||
|
yield* _onExternalEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
close() {
|
||||||
|
_fileMetadataUpdatedListener.end();
|
||||||
|
_albumUpdatedListener.end();
|
||||||
|
_fileRemovedListener.end();
|
||||||
|
_albumCreatedListener.end();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<ListAlbumBlocState> _onEventQuery(ListAlbumBlocQuery ev) async* {
|
||||||
|
yield ListAlbumBlocLoading(ev.account, state.albums);
|
||||||
|
|
||||||
|
ListAlbumBlocState cacheState = ListAlbumBlocInit();
|
||||||
|
await for (final s in _queryOffline(ev, () => cacheState)) {
|
||||||
|
cacheState = s;
|
||||||
|
}
|
||||||
|
yield ListAlbumBlocLoading(ev.account, cacheState.albums);
|
||||||
|
|
||||||
|
ListAlbumBlocState newState = ListAlbumBlocInit();
|
||||||
|
if (cacheState.albums.isEmpty) {
|
||||||
|
await for (final s in _queryOnline(ev, () => newState)) {
|
||||||
|
newState = s;
|
||||||
|
yield s;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await for (final s in _queryOnline(ev, () => newState)) {
|
||||||
|
newState = s;
|
||||||
|
}
|
||||||
|
if (newState is ListAlbumBlocSuccess) {
|
||||||
|
yield newState;
|
||||||
|
} else if (newState is ListAlbumBlocFailure) {
|
||||||
|
yield ListAlbumBlocFailure(
|
||||||
|
ev.account, cacheState.albums, newState.exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<ListAlbumBlocState> _onExternalEvent(
|
||||||
|
_ListAlbumBlocExternalEvent ev) async* {
|
||||||
|
yield ListAlbumBlocInconsistent(state.account, state.albums);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFileMetadataUpdatedEvent(FileMetadataUpdatedEvent ev) {
|
||||||
|
if (state is ListAlbumBlocInit) {
|
||||||
|
// no data in this bloc, ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
add(_ListAlbumBlocExternalEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAlbumUpdatedEvent(AlbumUpdatedEvent ev) {
|
||||||
|
if (state is ListAlbumBlocInit) {
|
||||||
|
// no data in this bloc, ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
add(_ListAlbumBlocExternalEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFileRemovedEvent(FileRemovedEvent ev) {
|
||||||
|
if (state is ListAlbumBlocInit) {
|
||||||
|
// no data in this bloc, ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAlbumFile(ev.file)) {
|
||||||
|
add(_ListAlbumBlocExternalEvent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAlbumCreatedEvent(AlbumCreatedEvent ev) {
|
||||||
|
if (state is ListAlbumBlocInit) {
|
||||||
|
// no data in this bloc, ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
add(_ListAlbumBlocExternalEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<ListAlbumBlocState> _queryOffline(
|
||||||
|
ListAlbumBlocQuery ev, ListAlbumBlocState Function() getState) =>
|
||||||
|
_queryWithAlbumDataSource(
|
||||||
|
ev, getState, FileAppDbDataSource(), AlbumAppDbDataSource());
|
||||||
|
|
||||||
|
Stream<ListAlbumBlocState> _queryOnline(
|
||||||
|
ListAlbumBlocQuery ev, ListAlbumBlocState Function() getState) =>
|
||||||
|
_queryWithAlbumDataSource(
|
||||||
|
ev, getState, FileCachedDataSource(), AlbumCachedDataSource());
|
||||||
|
|
||||||
|
Stream<ListAlbumBlocState> _queryWithAlbumDataSource(
|
||||||
|
ListAlbumBlocQuery ev,
|
||||||
|
ListAlbumBlocState Function() getState,
|
||||||
|
FileDataSource fileDataSource,
|
||||||
|
AlbumDataSource albumDataSrc) async* {
|
||||||
|
try {
|
||||||
|
final results = await ListAlbum(
|
||||||
|
FileRepo(fileDataSource), AlbumRepo(albumDataSrc))(ev.account);
|
||||||
|
yield ListAlbumBlocSuccess(ev.account, results);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[_queryWithAlbumDataSource] Exception while request", e);
|
||||||
|
yield ListAlbumBlocFailure(ev.account, getState().albums, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AppEventListener<FileMetadataUpdatedEvent> _fileMetadataUpdatedListener;
|
||||||
|
AppEventListener<AlbumUpdatedEvent> _albumUpdatedListener;
|
||||||
|
AppEventListener<FileRemovedEvent> _fileRemovedListener;
|
||||||
|
AppEventListener<AlbumCreatedEvent> _albumCreatedListener;
|
||||||
|
|
||||||
|
static final _log = Logger("bloc.list_album.ListAlbumBloc");
|
||||||
|
}
|
126
lib/bloc/ls_dir.dart
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/iterable_extension.dart';
|
||||||
|
import 'package:nc_photos/use_case/ls.dart';
|
||||||
|
|
||||||
|
class LsDirBlocItem {
|
||||||
|
LsDirBlocItem(this.file, this.children);
|
||||||
|
|
||||||
|
File file;
|
||||||
|
|
||||||
|
/// Child directories under this directory, or null if this isn't a directory
|
||||||
|
List<LsDirBlocItem> children;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class LsDirBlocEvent {
|
||||||
|
const LsDirBlocEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class LsDirBlocQuery extends LsDirBlocEvent {
|
||||||
|
const LsDirBlocQuery(this.account, this.roots);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"account: $account, "
|
||||||
|
"roots: ${roots.map((e) => e.path).toReadableString()}, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final List<File> roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class LsDirBlocState {
|
||||||
|
const LsDirBlocState(this._account, this._items);
|
||||||
|
|
||||||
|
Account get account => _account;
|
||||||
|
List<LsDirBlocItem> get items => _items;
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"account: $account, "
|
||||||
|
"items: List {length: ${items.length}}, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account _account;
|
||||||
|
final List<LsDirBlocItem> _items;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LsDirBlocInit extends LsDirBlocState {
|
||||||
|
const LsDirBlocInit() : super(null, const []);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LsDirBlocLoading extends LsDirBlocState {
|
||||||
|
const LsDirBlocLoading(Account account, List<LsDirBlocItem> items)
|
||||||
|
: super(account, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LsDirBlocSuccess extends LsDirBlocState {
|
||||||
|
const LsDirBlocSuccess(Account account, List<LsDirBlocItem> items)
|
||||||
|
: super(account, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LsDirBlocFailure extends LsDirBlocState {
|
||||||
|
const LsDirBlocFailure(
|
||||||
|
Account account, List<LsDirBlocItem> items, this.exception)
|
||||||
|
: super(account, items);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"super: ${super.toString()}, "
|
||||||
|
"exception: $exception, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final dynamic exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bloc that return all directories under a dir recursively
|
||||||
|
class LsDirBloc extends Bloc<LsDirBlocEvent, LsDirBlocState> {
|
||||||
|
LsDirBloc() : super(LsDirBlocInit());
|
||||||
|
|
||||||
|
@override
|
||||||
|
mapEventToState(LsDirBlocEvent event) async* {
|
||||||
|
_log.info("[mapEventToState] $event");
|
||||||
|
if (event is LsDirBlocQuery) {
|
||||||
|
yield* _onEventQuery(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<LsDirBlocState> _onEventQuery(LsDirBlocQuery ev) async* {
|
||||||
|
try {
|
||||||
|
yield LsDirBlocLoading(ev.account, state.items);
|
||||||
|
|
||||||
|
final products = <LsDirBlocItem>[];
|
||||||
|
for (final r in ev.roots) {
|
||||||
|
products.addAll(await _query(ev, r));
|
||||||
|
}
|
||||||
|
yield LsDirBlocSuccess(ev.account, products);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[_onEventQuery] Exception while request", e);
|
||||||
|
yield LsDirBlocFailure(ev.account, state.items, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<LsDirBlocItem>> _query(LsDirBlocQuery ev, File root) async {
|
||||||
|
final products = <LsDirBlocItem>[];
|
||||||
|
final files = await Ls(FileRepo(FileWebdavDataSource()))(ev.account, root);
|
||||||
|
for (final f in files) {
|
||||||
|
if (f.isCollection) {
|
||||||
|
products.add(LsDirBlocItem(f, await _query(ev, f)));
|
||||||
|
}
|
||||||
|
// we don't want normal files
|
||||||
|
}
|
||||||
|
return products;
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _log = Logger("bloc.ls_dir.LsDirBloc");
|
||||||
|
}
|
205
lib/bloc/scan_dir.dart
Normal file
|
@ -0,0 +1,205 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/event/event.dart';
|
||||||
|
import 'package:nc_photos/iterable_extension.dart';
|
||||||
|
import 'package:nc_photos/use_case/scan_dir.dart';
|
||||||
|
|
||||||
|
abstract class ScanDirBlocEvent {
|
||||||
|
const ScanDirBlocEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScanDirBlocQuery extends ScanDirBlocEvent {
|
||||||
|
const ScanDirBlocQuery(this.account, this.roots);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"account: $account, "
|
||||||
|
"roots: ${roots.map((e) => e.path).toReadableString()}, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final List<File> roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An external event has happened and may affect the state of this bloc
|
||||||
|
class _ScanDirBlocExternalEvent extends ScanDirBlocEvent {
|
||||||
|
const _ScanDirBlocExternalEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class ScanDirBlocState {
|
||||||
|
const ScanDirBlocState(this._account, this._files);
|
||||||
|
|
||||||
|
Account get account => _account;
|
||||||
|
List<File> get files => _files;
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"account: $account, "
|
||||||
|
"files: List {length: ${files.length}}, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account _account;
|
||||||
|
final List<File> _files;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScanDirBlocInit extends ScanDirBlocState {
|
||||||
|
const ScanDirBlocInit() : super(null, const []);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScanDirBlocLoading extends ScanDirBlocState {
|
||||||
|
const ScanDirBlocLoading(Account account, List<File> files)
|
||||||
|
: super(account, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScanDirBlocSuccess extends ScanDirBlocState {
|
||||||
|
const ScanDirBlocSuccess(Account account, List<File> files)
|
||||||
|
: super(account, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScanDirBlocFailure extends ScanDirBlocState {
|
||||||
|
const ScanDirBlocFailure(Account account, List<File> files, this.exception)
|
||||||
|
: super(account, files);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"super: ${super.toString()}, "
|
||||||
|
"exception: $exception, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final dynamic exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state of this bloc is inconsistent. This typically means that the data
|
||||||
|
/// may have been changed externally
|
||||||
|
class ScanDirBlocInconsistent extends ScanDirBlocState {
|
||||||
|
const ScanDirBlocInconsistent(Account account, List<File> files)
|
||||||
|
: super(account, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bloc that return all files under a dir recursively
|
||||||
|
///
|
||||||
|
/// See [ScanDir]
|
||||||
|
class ScanDirBloc extends Bloc<ScanDirBlocEvent, ScanDirBlocState> {
|
||||||
|
ScanDirBloc() : super(ScanDirBlocInit()) {
|
||||||
|
_fileRemovedEventListener =
|
||||||
|
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
|
||||||
|
_fileMetadataUpdatedEventListener =
|
||||||
|
AppEventListener<FileMetadataUpdatedEvent>(_onFileMetadataUpdatedEvent);
|
||||||
|
_fileRemovedEventListener.begin();
|
||||||
|
_fileMetadataUpdatedEventListener.begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
mapEventToState(ScanDirBlocEvent event) async* {
|
||||||
|
_log.info("[mapEventToState] $event");
|
||||||
|
if (event is ScanDirBlocQuery) {
|
||||||
|
yield* _onEventQuery(event);
|
||||||
|
} else if (event is _ScanDirBlocExternalEvent) {
|
||||||
|
yield* _onExternalEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
close() {
|
||||||
|
_fileRemovedEventListener.end();
|
||||||
|
_fileMetadataUpdatedEventListener.end();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<ScanDirBlocState> _onEventQuery(ScanDirBlocQuery ev) async* {
|
||||||
|
yield ScanDirBlocLoading(ev.account, state.files);
|
||||||
|
|
||||||
|
ScanDirBlocState cacheState = ScanDirBlocInit();
|
||||||
|
await for (final s in _queryOffline(ev, () => cacheState)) {
|
||||||
|
cacheState = s;
|
||||||
|
}
|
||||||
|
yield ScanDirBlocLoading(ev.account, cacheState.files);
|
||||||
|
|
||||||
|
ScanDirBlocState newState = ScanDirBlocInit();
|
||||||
|
if (cacheState.files.isEmpty) {
|
||||||
|
await for (final s in _queryOnline(ev, () => newState)) {
|
||||||
|
newState = s;
|
||||||
|
yield s;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await for (final s in _queryOnline(ev, () => newState)) {
|
||||||
|
newState = s;
|
||||||
|
}
|
||||||
|
if (newState is ScanDirBlocSuccess) {
|
||||||
|
yield newState;
|
||||||
|
} else if (newState is ScanDirBlocFailure) {
|
||||||
|
yield ScanDirBlocFailure(
|
||||||
|
ev.account, cacheState.files, newState.exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<ScanDirBlocState> _onExternalEvent(
|
||||||
|
_ScanDirBlocExternalEvent ev) async* {
|
||||||
|
yield ScanDirBlocInconsistent(state.account, state.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFileRemovedEvent(FileRemovedEvent ev) {
|
||||||
|
if (state is ScanDirBlocInit) {
|
||||||
|
// no data in this bloc, ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
add(_ScanDirBlocExternalEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFileMetadataUpdatedEvent(FileMetadataUpdatedEvent ev) {
|
||||||
|
if (state is ScanDirBlocInit) {
|
||||||
|
// no data in this bloc, ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
add(_ScanDirBlocExternalEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<ScanDirBlocState> _queryOffline(
|
||||||
|
ScanDirBlocQuery ev, ScanDirBlocState Function() getState) =>
|
||||||
|
_queryWithFileDataSource(ev, getState, FileAppDbDataSource());
|
||||||
|
|
||||||
|
Stream<ScanDirBlocState> _queryOnline(
|
||||||
|
ScanDirBlocQuery ev, ScanDirBlocState Function() getState) =>
|
||||||
|
_queryWithFileDataSource(ev, getState, FileCachedDataSource());
|
||||||
|
|
||||||
|
Stream<ScanDirBlocState> _queryWithFileDataSource(ScanDirBlocQuery ev,
|
||||||
|
ScanDirBlocState Function() getState, FileDataSource dataSrc) async* {
|
||||||
|
try {
|
||||||
|
for (final r in ev.roots) {
|
||||||
|
final dataStream = ScanDir(FileRepo(dataSrc))(ev.account, r);
|
||||||
|
await for (final d in dataStream) {
|
||||||
|
if (d is Exception || d is Error) {
|
||||||
|
throw d;
|
||||||
|
}
|
||||||
|
yield ScanDirBlocLoading(ev.account, getState().files + d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield ScanDirBlocSuccess(ev.account, getState().files);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[_queryWithFileDataSource] Exception while request", e);
|
||||||
|
yield ScanDirBlocFailure(ev.account, getState().files, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AppEventListener<FileRemovedEvent> _fileRemovedEventListener;
|
||||||
|
AppEventListener<FileMetadataUpdatedEvent> _fileMetadataUpdatedEventListener;
|
||||||
|
|
||||||
|
static final _log = Logger("bloc.scan_dir.ScanDirBloc");
|
||||||
|
}
|
27
lib/cache_manager_util.dart
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
// ignore: implementation_imports
|
||||||
|
import 'package:flutter_cache_manager/src/cache_store.dart';
|
||||||
|
|
||||||
|
class CancelableGetFile {
|
||||||
|
CancelableGetFile(this.store);
|
||||||
|
|
||||||
|
Future<FileInfo> getFileUntil(String key,
|
||||||
|
{bool ignoreMemCache = false}) async {
|
||||||
|
FileInfo product;
|
||||||
|
while (product == null && _shouldRun) {
|
||||||
|
product = await store.getFile(key, ignoreMemCache: ignoreMemCache);
|
||||||
|
await Future.delayed(Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
return product ?? Future.error("Interrupted");
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
_shouldRun = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isGood => _shouldRun;
|
||||||
|
|
||||||
|
final CacheStore store;
|
||||||
|
|
||||||
|
bool _shouldRun = true;
|
||||||
|
}
|
11
lib/connectivity_util.dart
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import 'package:connectivity/connectivity.dart';
|
||||||
|
|
||||||
|
Future<void> waitUntilWifi() async {
|
||||||
|
while (true) {
|
||||||
|
final result = await Connectivity().checkConnectivity();
|
||||||
|
if (result == ConnectivityResult.wifi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(seconds: 5));
|
||||||
|
}
|
||||||
|
}
|
17
lib/double_extension.dart
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import 'package:nc_photos/string_extension.dart';
|
||||||
|
|
||||||
|
extension DoubleExtension on double {
|
||||||
|
/// Same as toStringAsFixed but with trailing zeros truncated
|
||||||
|
String toStringAsFixedTruncated(int fractionDigits) {
|
||||||
|
String tmp = toStringAsFixed(fractionDigits);
|
||||||
|
if (fractionDigits == 0) {
|
||||||
|
return tmp;
|
||||||
|
}
|
||||||
|
tmp = tmp.trimRightAny("0");
|
||||||
|
if (tmp.endsWith(".")) {
|
||||||
|
return tmp.substring(0, tmp.length - 1);
|
||||||
|
} else {
|
||||||
|
return tmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
426
lib/entity/album.dart
Normal file
|
@ -0,0 +1,426 @@
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:idb_sqflite/idb_sqflite.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api.dart';
|
||||||
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||||
|
import 'package:nc_photos/app_db.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/exception.dart';
|
||||||
|
import 'package:nc_photos/iterable_extension.dart';
|
||||||
|
import 'package:nc_photos/use_case/get_file_binary.dart';
|
||||||
|
import 'package:nc_photos/use_case/ls.dart';
|
||||||
|
import 'package:nc_photos/use_case/put_file_binary.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
|
String getAlbumFileRoot(Account account) =>
|
||||||
|
"${api_util.getWebdavRootUrlRelative(account)}/.com.nkming.nc_photos";
|
||||||
|
|
||||||
|
bool isAlbumFile(File file) =>
|
||||||
|
path.basename(path.dirname(file.path)) == ".com.nkming.nc_photos";
|
||||||
|
|
||||||
|
abstract class AlbumItem {
|
||||||
|
AlbumItem();
|
||||||
|
|
||||||
|
factory AlbumItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
final type = json["type"];
|
||||||
|
final content = json["content"];
|
||||||
|
switch (type) {
|
||||||
|
case AlbumFileItem._type:
|
||||||
|
return AlbumFileItem.fromJson(content.cast<String, dynamic>());
|
||||||
|
default:
|
||||||
|
_log.severe("[fromJson] Unknown type: $type");
|
||||||
|
throw ArgumentError.value(type, "type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
String getType() {
|
||||||
|
if (this is AlbumFileItem) {
|
||||||
|
return AlbumFileItem._type;
|
||||||
|
} else {
|
||||||
|
throw StateError("Unknwon subtype");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": getType(),
|
||||||
|
"content": toContentJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toContentJson();
|
||||||
|
|
||||||
|
static final _log = Logger("entity.album.AlbumItem");
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumFileItem extends AlbumItem {
|
||||||
|
AlbumFileItem({this.file});
|
||||||
|
|
||||||
|
factory AlbumFileItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AlbumFileItem(
|
||||||
|
file: File.fromJson(json["file"].cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"file: $file"
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
toContentJson() {
|
||||||
|
return {
|
||||||
|
"file": file.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
final File file;
|
||||||
|
|
||||||
|
static const _type = "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable object that represents an album
|
||||||
|
class Album {
|
||||||
|
Album({
|
||||||
|
DateTime lastUpdated,
|
||||||
|
@required String name,
|
||||||
|
@required List<AlbumItem> items,
|
||||||
|
this.albumFile,
|
||||||
|
}) : this.lastUpdated = (lastUpdated ?? DateTime.now()).toUtc(),
|
||||||
|
this.name = name ?? "",
|
||||||
|
this.items = UnmodifiableListView(items);
|
||||||
|
|
||||||
|
factory Album.versioned({
|
||||||
|
int version,
|
||||||
|
DateTime lastUpdated,
|
||||||
|
@required String name,
|
||||||
|
@required List<AlbumItem> items,
|
||||||
|
File albumFile,
|
||||||
|
}) {
|
||||||
|
// there's only one version right now
|
||||||
|
return Album(
|
||||||
|
lastUpdated: lastUpdated,
|
||||||
|
name: name,
|
||||||
|
items: items,
|
||||||
|
albumFile: albumFile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Album.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Album.versioned(
|
||||||
|
version: json["version"],
|
||||||
|
lastUpdated: json["lastUpdated"] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json["lastUpdated"]),
|
||||||
|
name: json["name"],
|
||||||
|
items: (json["items"] as List)
|
||||||
|
.map((e) => AlbumItem.fromJson(e.cast<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
albumFile: json["albumFile"] == null
|
||||||
|
? null
|
||||||
|
: File.fromJson(json["albumFile"].cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"lastUpdated: $lastUpdated, "
|
||||||
|
"name: $name, "
|
||||||
|
"items: ${items.toReadableString()}, "
|
||||||
|
"albumFile: $albumFile, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a copy with specified field modified
|
||||||
|
///
|
||||||
|
/// [lastUpdated] is handled differently where if null, the current time will
|
||||||
|
/// be used. In order to keep [lastUpdated], you must explicitly assign it
|
||||||
|
/// with value from this
|
||||||
|
Album copyWith({
|
||||||
|
DateTime lastUpdated,
|
||||||
|
String name,
|
||||||
|
List<AlbumItem> items,
|
||||||
|
File albumFile,
|
||||||
|
}) {
|
||||||
|
return Album(
|
||||||
|
lastUpdated: lastUpdated,
|
||||||
|
name: name ?? this.name,
|
||||||
|
items: items ?? this.items,
|
||||||
|
albumFile: albumFile ?? this.albumFile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _toRemoteJson() {
|
||||||
|
return {
|
||||||
|
"version": version,
|
||||||
|
"lastUpdated": lastUpdated.toIso8601String(),
|
||||||
|
"name": name,
|
||||||
|
"items": items.map((e) => e.toJson()).toList(),
|
||||||
|
// ignore albumFile
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _toAppDbJson() {
|
||||||
|
return {
|
||||||
|
"version": version,
|
||||||
|
"lastUpdated": lastUpdated.toIso8601String(),
|
||||||
|
"name": name,
|
||||||
|
"items": items.map((e) => e.toJson()).toList(),
|
||||||
|
if (albumFile != null) "albumFile": albumFile.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
final DateTime lastUpdated;
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// Immutable list of items. Modifying the list will result in an error
|
||||||
|
final List<AlbumItem> items;
|
||||||
|
|
||||||
|
/// How is this album stored on server
|
||||||
|
///
|
||||||
|
/// This field is typically only meaningful when returned by [AlbumRepo.get]
|
||||||
|
final File albumFile;
|
||||||
|
|
||||||
|
/// versioning of this class, use to upgrade old persisted album
|
||||||
|
static const version = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumRepo {
|
||||||
|
AlbumRepo(this.dataSrc);
|
||||||
|
|
||||||
|
/// See [AlbumDataSource.get]
|
||||||
|
Future<Album> get(Account account, File albumFile) =>
|
||||||
|
this.dataSrc.get(account, albumFile);
|
||||||
|
|
||||||
|
/// See [AlbumDataSource.create]
|
||||||
|
Future<Album> create(Account account, Album album) =>
|
||||||
|
this.dataSrc.create(account, album);
|
||||||
|
|
||||||
|
/// See [AlbumDataSource.update]
|
||||||
|
Future<void> update(Account account, Album album) =>
|
||||||
|
this.dataSrc.update(account, album);
|
||||||
|
|
||||||
|
/// See [AlbumDataSource.cleanUp]
|
||||||
|
Future<void> cleanUp(Account account, List<File> albumFiles) =>
|
||||||
|
this.dataSrc.cleanUp(account, albumFiles);
|
||||||
|
|
||||||
|
final AlbumDataSource dataSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class AlbumDataSource {
|
||||||
|
/// Return the album defined by [albumFile]
|
||||||
|
Future<Album> get(Account account, File albumFile);
|
||||||
|
|
||||||
|
// Create a new album
|
||||||
|
Future<Album> create(Account account, Album album);
|
||||||
|
|
||||||
|
/// Update an album
|
||||||
|
Future<void> update(Account account, Album album);
|
||||||
|
|
||||||
|
/// Clean up cached albums
|
||||||
|
///
|
||||||
|
/// Remove dangling albums in cache not listed in [albumFiles]. Do nothing if
|
||||||
|
/// this data source does not cache previous results
|
||||||
|
Future<void> cleanUp(Account account, List<File> albumFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumRemoteDataSource implements AlbumDataSource {
|
||||||
|
@override
|
||||||
|
get(Account account, File albumFile) async {
|
||||||
|
_log.info("[get] ${albumFile.path}");
|
||||||
|
final fileRepo = FileRepo(FileWebdavDataSource());
|
||||||
|
final data = await GetFileBinary(fileRepo)(account, albumFile);
|
||||||
|
try {
|
||||||
|
return Album.fromJson(jsonDecode(utf8.decode(data)))
|
||||||
|
.copyWith(albumFile: albumFile);
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
dynamic d = data;
|
||||||
|
try {
|
||||||
|
d = utf8.decode(data);
|
||||||
|
} catch (_) {}
|
||||||
|
_log.severe("[get] Invalid json data: $d", e, stacktrace);
|
||||||
|
throw FormatException("Invalid album format");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
create(Account account, Album album) async {
|
||||||
|
_log.info("[create]");
|
||||||
|
final fileName = _makeAlbumFileName();
|
||||||
|
final filePath = "${getAlbumFileRoot(account)}/$fileName";
|
||||||
|
final fileRepo = FileRepo(FileWebdavDataSource());
|
||||||
|
try {
|
||||||
|
await PutFileBinary(fileRepo)(
|
||||||
|
account, filePath, utf8.encode(jsonEncode(album._toRemoteJson())));
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (e.response.statusCode == 404) {
|
||||||
|
_log.info("[create] Missing album dir, creating");
|
||||||
|
// no dir
|
||||||
|
await _createDir(account);
|
||||||
|
// then retry
|
||||||
|
await PutFileBinary(fileRepo)(
|
||||||
|
account, filePath, utf8.encode(jsonEncode(album._toRemoteJson())));
|
||||||
|
} else {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// query album file
|
||||||
|
final list = await Ls(fileRepo)(account, File(path: filePath),
|
||||||
|
shouldExcludeRootDir: false);
|
||||||
|
return album.copyWith(albumFile: list.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
update(Account account, Album album) async {
|
||||||
|
_log.info("[update] ${album.albumFile.path}");
|
||||||
|
final fileRepo = FileRepo(FileWebdavDataSource());
|
||||||
|
await PutFileBinary(fileRepo)(account, album.albumFile.path,
|
||||||
|
utf8.encode(jsonEncode(album._toRemoteJson())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
cleanUp(Account account, List<File> albumFiles) async {}
|
||||||
|
|
||||||
|
String _makeAlbumFileName() {
|
||||||
|
// just make up something
|
||||||
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final random = Random().nextInt(0xFFFFFF);
|
||||||
|
return "${timestamp.toRadixString(16)}-${random.toRadixString(16).padLeft(6, '0')}.json";
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createDir(Account account) {
|
||||||
|
return Api(account).files().mkcol(path: getAlbumFileRoot(account));
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _log = Logger("entity.album.AlbumRemoteDataSource");
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumAppDbDataSource implements AlbumDataSource {
|
||||||
|
@override
|
||||||
|
get(Account account, File albumFile) {
|
||||||
|
_log.info("[get] ${albumFile.path}");
|
||||||
|
return AppDb.use((db) async {
|
||||||
|
final transaction = db.transaction(AppDb.albumStoreName, idbModeReadOnly);
|
||||||
|
final store = transaction.objectStore(AppDb.albumStoreName);
|
||||||
|
final Map result =
|
||||||
|
await store.getObject("${_getCacheKey(account, albumFile)}");
|
||||||
|
if (result != null) {
|
||||||
|
return Album.fromJson(result.cast<String, dynamic>());
|
||||||
|
} else {
|
||||||
|
throw CacheNotFoundException(
|
||||||
|
"No entry: ${_getCacheKey(account, albumFile)}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
create(Account account, Album album) async {
|
||||||
|
_log.info("[create]");
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
update(Account account, Album album) {
|
||||||
|
_log.info("[update] ${album.albumFile.path}");
|
||||||
|
return AppDb.use((db) async {
|
||||||
|
final transaction =
|
||||||
|
db.transaction(AppDb.albumStoreName, idbModeReadWrite);
|
||||||
|
final store = transaction.objectStore(AppDb.albumStoreName);
|
||||||
|
await store.put(
|
||||||
|
album._toAppDbJson(), _getCacheKey(account, album.albumFile));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
cleanUp(Account account, List<File> albumFiles) async {}
|
||||||
|
|
||||||
|
static final _log = Logger("entity.album.AlbumAppDbDataSource");
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumCachedDataSource implements AlbumDataSource {
|
||||||
|
@override
|
||||||
|
get(Account account, File albumFile) async {
|
||||||
|
try {
|
||||||
|
final cache = await _appDbSrc.get(account, albumFile);
|
||||||
|
if (cache.albumFile.etag?.isNotEmpty == true &&
|
||||||
|
cache.albumFile.etag == albumFile.etag) {
|
||||||
|
// cache is good
|
||||||
|
_log.fine("[get] etag matched for ${_getCacheKey(account, albumFile)}");
|
||||||
|
return cache;
|
||||||
|
} else {
|
||||||
|
_log.info(
|
||||||
|
"[get] Remote content updated for ${_getCacheKey(account, albumFile)}");
|
||||||
|
}
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
// no cache
|
||||||
|
if (e is! CacheNotFoundException) {
|
||||||
|
_log.severe("[get] Cache failure", e, stacktrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// no cache
|
||||||
|
final remote = await _remoteSrc.get(account, albumFile);
|
||||||
|
await _cacheResult(account, albumFile, remote);
|
||||||
|
return remote;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
update(Account account, Album album) async {
|
||||||
|
await _remoteSrc.update(account, album);
|
||||||
|
await _appDbSrc.update(account, album);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
create(Account account, Album album) => _remoteSrc.create(account, album);
|
||||||
|
|
||||||
|
@override
|
||||||
|
cleanUp(Account account, List<File> albumFiles) async {
|
||||||
|
AppDb.use((db) async {
|
||||||
|
final transaction =
|
||||||
|
db.transaction(AppDb.albumStoreName, idbModeReadWrite);
|
||||||
|
final store = transaction.objectStore(AppDb.albumStoreName);
|
||||||
|
final keyPrefix = _getCacheKeyPrefix(account);
|
||||||
|
final range = KeyRange.bound("$keyPrefix/", "$keyPrefix/\uffff");
|
||||||
|
final danglingKeys = await store
|
||||||
|
// get all albums for this account
|
||||||
|
.openKeyCursor(range: range, autoAdvance: true)
|
||||||
|
.map((cursor) => cursor.key)
|
||||||
|
// and pick the dangling ones
|
||||||
|
.where((key) =>
|
||||||
|
!albumFiles.any((f) => key == "${_getCacheKey(account, f)}"))
|
||||||
|
.toList();
|
||||||
|
for (final k in danglingKeys) {
|
||||||
|
_log.fine("[cleanUp] Removing DB entry: $k");
|
||||||
|
await store.delete(k);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cacheResult(Account account, File albumFile, Album result) {
|
||||||
|
return AppDb.use((db) async {
|
||||||
|
final transaction =
|
||||||
|
db.transaction(AppDb.albumStoreName, idbModeReadWrite);
|
||||||
|
final store = transaction.objectStore(AppDb.albumStoreName);
|
||||||
|
await store.put(result._toAppDbJson(), _getCacheKey(account, albumFile));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final _remoteSrc = AlbumRemoteDataSource();
|
||||||
|
final _appDbSrc = AlbumAppDbDataSource();
|
||||||
|
|
||||||
|
static final _log = Logger("entity.album.AlbumCachedDataSource");
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getCacheKeyPrefix(Account account) => account.url;
|
||||||
|
|
||||||
|
String _getCacheKey(Account account, File albumFile) =>
|
||||||
|
"${_getCacheKeyPrefix(account)}/${albumFile.path}";
|
85
lib/entity/exif.dart
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:exifdart/exifdart.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class Exif {
|
||||||
|
Exif(this.data);
|
||||||
|
|
||||||
|
dynamic operator [](String key) => data[key];
|
||||||
|
|
||||||
|
bool containsKey(String key) => data.containsKey(key);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return data.map((key, value) {
|
||||||
|
var jsonValue;
|
||||||
|
if (key == "MakerNote") {
|
||||||
|
jsonValue = base64UrlEncode(value);
|
||||||
|
} else if (value is Rational) {
|
||||||
|
jsonValue = value.toJson();
|
||||||
|
} else if (value is List<Rational>) {
|
||||||
|
jsonValue = value.map((e) => e.toJson()).toList();
|
||||||
|
} else {
|
||||||
|
jsonValue = value;
|
||||||
|
}
|
||||||
|
return MapEntry(key, jsonValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Exif.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Exif(json.map((key, value) {
|
||||||
|
var exifValue;
|
||||||
|
if (key == "MakerNote") {
|
||||||
|
exifValue = base64Decode(value);
|
||||||
|
} else if (value is Map) {
|
||||||
|
exifValue = Rational.fromJson(value.cast<String, dynamic>());
|
||||||
|
} else if (value is List<Map>) {
|
||||||
|
exifValue = value
|
||||||
|
.map((e) => Rational.fromJson(e.cast<String, dynamic>()))
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
exifValue = value;
|
||||||
|
}
|
||||||
|
return MapEntry(key, exifValue);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
final dataStr = data.entries.map((e) {
|
||||||
|
if (e.key == "MakerNote") {
|
||||||
|
return "${e.key}: '${base64UrlEncode(e.value)}'";
|
||||||
|
} else {
|
||||||
|
return "${e.key}: '${e.value}'";
|
||||||
|
}
|
||||||
|
}).join(", ");
|
||||||
|
return "$runtimeType {$dataStr}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 0x010f Make
|
||||||
|
String get make => data["Make"];
|
||||||
|
|
||||||
|
/// 0x0110 Model
|
||||||
|
String get model => data["Model"];
|
||||||
|
|
||||||
|
/// 0x9003 DateTimeOriginal
|
||||||
|
DateTime get dateTimeOriginal => data.containsKey("DateTimeOriginal")
|
||||||
|
? dateTimeFormat.parse(data["DateTimeOriginal"])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// 0x829a ExposureTime
|
||||||
|
Rational get exposureTime => data["ExposureTime"];
|
||||||
|
|
||||||
|
/// 0x829d FNumber
|
||||||
|
Rational get fNumber => data["FNumber"];
|
||||||
|
|
||||||
|
/// 0x8827 ISO/ISOSpeedRatings/PhotographicSensitivity
|
||||||
|
int get isoSpeedRatings => data["ISOSpeedRatings"];
|
||||||
|
|
||||||
|
/// 0x920a FocalLength
|
||||||
|
Rational get focalLength => data["FocalLength"];
|
||||||
|
|
||||||
|
static final dateTimeFormat = DateFormat("yyyy:MM:dd HH:mm:ss");
|
||||||
|
|
||||||
|
final Map<String, dynamic> data;
|
||||||
|
}
|
627
lib/entity/file.dart
Normal file
|
@ -0,0 +1,627 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:idb_sqflite/idb_sqflite.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api.dart';
|
||||||
|
import 'package:nc_photos/app_db.dart';
|
||||||
|
import 'package:nc_photos/entity/exif.dart';
|
||||||
|
import 'package:nc_photos/entity/webdav_response_parser.dart';
|
||||||
|
import 'package:nc_photos/exception.dart';
|
||||||
|
import 'package:nc_photos/iterable_extension.dart';
|
||||||
|
import 'package:nc_photos/string_extension.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
int compareFileDateTimeDescending(File x, File y) {
|
||||||
|
final xDate = x.metadata?.exif?.dateTimeOriginal ?? x.lastModified;
|
||||||
|
final yDate = y.metadata?.exif?.dateTimeOriginal ?? y.lastModified;
|
||||||
|
final tmp = yDate.compareTo(xDate);
|
||||||
|
if (tmp != 0) {
|
||||||
|
return tmp;
|
||||||
|
} else {
|
||||||
|
// compare file name if files are modified at the same time
|
||||||
|
return x.path.compareTo(y.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable object that hold metadata of a [File]
|
||||||
|
class Metadata {
|
||||||
|
Metadata({
|
||||||
|
DateTime lastUpdated,
|
||||||
|
this.fileEtag,
|
||||||
|
this.imageWidth,
|
||||||
|
this.imageHeight,
|
||||||
|
this.exif,
|
||||||
|
}) : this.lastUpdated = (lastUpdated ?? DateTime.now()).toUtc();
|
||||||
|
|
||||||
|
/// Parse Metadata from [json]
|
||||||
|
///
|
||||||
|
/// If the version saved in json does not match the active one, the
|
||||||
|
/// corresponding upgrader will be called one by one to upgrade the json,
|
||||||
|
/// version by version until it reached the active version. If any upgrader
|
||||||
|
/// in the chain is null, the upgrade process will fail
|
||||||
|
factory Metadata.fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
MetadataUpgraderV1 upgraderV1,
|
||||||
|
}) {
|
||||||
|
final jsonVersion = json["version"];
|
||||||
|
if (jsonVersion < 2) {
|
||||||
|
json = upgraderV1?.call(json);
|
||||||
|
if (json == null) {
|
||||||
|
_log.info("[fromJson] Version $jsonVersion not compatible");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Metadata(
|
||||||
|
lastUpdated: json["lastUpdated"] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json["lastUpdated"]),
|
||||||
|
fileEtag: json["fileEtag"],
|
||||||
|
imageWidth: json["imageWidth"],
|
||||||
|
imageHeight: json["imageHeight"],
|
||||||
|
exif: json["exif"] == null
|
||||||
|
? null
|
||||||
|
: Exif.fromJson(json["exif"].cast<String, dynamic>()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"version": version,
|
||||||
|
"lastUpdated": lastUpdated.toIso8601String(),
|
||||||
|
if (fileEtag != null) "fileEtag": fileEtag,
|
||||||
|
if (imageWidth != null) "imageWidth": imageWidth,
|
||||||
|
if (imageHeight != null) "imageHeight": imageHeight,
|
||||||
|
if (exif != null) "exif": exif.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
var product = "$runtimeType {"
|
||||||
|
"lastUpdated: $lastUpdated, ";
|
||||||
|
if (fileEtag != null) {
|
||||||
|
product += "fileEtag: $fileEtag, ";
|
||||||
|
}
|
||||||
|
if (imageWidth != null) {
|
||||||
|
product += "imageWidth: $imageWidth, ";
|
||||||
|
}
|
||||||
|
if (imageHeight != null) {
|
||||||
|
product += "imageHeight: $imageHeight, ";
|
||||||
|
}
|
||||||
|
if (exif != null) {
|
||||||
|
product += "exif: $exif, ";
|
||||||
|
}
|
||||||
|
return product + "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
final DateTime lastUpdated;
|
||||||
|
|
||||||
|
/// Etag of the parent file when the metadata is saved
|
||||||
|
final String fileEtag;
|
||||||
|
final int imageWidth;
|
||||||
|
final int imageHeight;
|
||||||
|
final Exif exif;
|
||||||
|
|
||||||
|
/// versioning of this class, use to upgrade old persisted metadata
|
||||||
|
static const version = 2;
|
||||||
|
|
||||||
|
static final _log = Logger("entity.file.Metadata");
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class MetadataUpgrader {
|
||||||
|
Map<String, dynamic> call(Map<String, dynamic> json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upgrade v1 Metadata to v2
|
||||||
|
class MetadataUpgraderV1 implements MetadataUpgrader {
|
||||||
|
MetadataUpgraderV1({
|
||||||
|
@required this.fileContentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> call(Map<String, dynamic> json) {
|
||||||
|
if (fileContentType == "image/webp") {
|
||||||
|
// Version 1 metadata for webp is bugged, drop it
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final String fileContentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
class File {
|
||||||
|
File({
|
||||||
|
@required String path,
|
||||||
|
this.contentLength,
|
||||||
|
this.contentType,
|
||||||
|
this.etag,
|
||||||
|
this.lastModified,
|
||||||
|
this.isCollection,
|
||||||
|
this.usedBytes,
|
||||||
|
this.hasPreview,
|
||||||
|
this.metadata,
|
||||||
|
}) : this.path = path.trimRightAny("/");
|
||||||
|
|
||||||
|
factory File.fromJson(Map<String, dynamic> json) {
|
||||||
|
return File(
|
||||||
|
path: json["path"],
|
||||||
|
contentLength: json["contentLength"],
|
||||||
|
contentType: json["contentType"],
|
||||||
|
etag: json["etag"],
|
||||||
|
lastModified: json["lastModified"] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json["lastModified"]),
|
||||||
|
isCollection: json["isCollection"],
|
||||||
|
usedBytes: json["usedBytes"],
|
||||||
|
hasPreview: json["hasPreview"],
|
||||||
|
metadata: json["metadata"] == null
|
||||||
|
? null
|
||||||
|
: Metadata.fromJson(
|
||||||
|
json["metadata"].cast<String, dynamic>(),
|
||||||
|
upgraderV1: MetadataUpgraderV1(
|
||||||
|
fileContentType: json["contentType"],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
var product = "$runtimeType {"
|
||||||
|
"path: '$path', ";
|
||||||
|
if (contentLength != null) {
|
||||||
|
product += "contentLength: $contentLength, ";
|
||||||
|
}
|
||||||
|
if (contentType != null) {
|
||||||
|
product += "contentType: '$contentType', ";
|
||||||
|
}
|
||||||
|
if (etag != null) {
|
||||||
|
product += "etag: '$etag', ";
|
||||||
|
}
|
||||||
|
if (lastModified != null) {
|
||||||
|
product += "lastModified: $lastModified, ";
|
||||||
|
}
|
||||||
|
if (isCollection != null) {
|
||||||
|
product += "isCollection: $isCollection, ";
|
||||||
|
}
|
||||||
|
if (usedBytes != null) {
|
||||||
|
product += "usedBytes: $usedBytes, ";
|
||||||
|
}
|
||||||
|
if (hasPreview != null) {
|
||||||
|
product += "hasPreview: $hasPreview, ";
|
||||||
|
}
|
||||||
|
if (metadata != null) {
|
||||||
|
product += "metadata: $metadata, ";
|
||||||
|
}
|
||||||
|
return product + "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"path": path,
|
||||||
|
if (contentLength != null) "contentLength": contentLength,
|
||||||
|
if (contentType != null) "contentType": contentType,
|
||||||
|
if (etag != null) "etag": etag,
|
||||||
|
if (lastModified != null) "lastModified": lastModified.toIso8601String(),
|
||||||
|
if (isCollection != null) "isCollection": isCollection,
|
||||||
|
if (usedBytes != null) "usedBytes": usedBytes,
|
||||||
|
if (hasPreview != null) "hasPreview": hasPreview,
|
||||||
|
if (metadata != null) "metadata": metadata.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
File copyWith({
|
||||||
|
String path,
|
||||||
|
int contentLength,
|
||||||
|
String contentType,
|
||||||
|
String etag,
|
||||||
|
DateTime lastModified,
|
||||||
|
bool isCollection,
|
||||||
|
int usedBytes,
|
||||||
|
bool hasPreview,
|
||||||
|
Metadata metadata,
|
||||||
|
}) {
|
||||||
|
return File(
|
||||||
|
path: path ?? this.path,
|
||||||
|
contentLength: contentLength ?? this.contentLength,
|
||||||
|
contentType: contentType ?? this.contentType,
|
||||||
|
etag: etag ?? this.etag,
|
||||||
|
lastModified: lastModified ?? this.lastModified,
|
||||||
|
isCollection: isCollection ?? this.isCollection,
|
||||||
|
usedBytes: usedBytes ?? this.usedBytes,
|
||||||
|
hasPreview: hasPreview ?? this.hasPreview,
|
||||||
|
metadata: metadata ?? this.metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
File withoutMetadata() {
|
||||||
|
return File(
|
||||||
|
path: path,
|
||||||
|
contentLength: contentLength,
|
||||||
|
contentType: contentType,
|
||||||
|
etag: etag,
|
||||||
|
lastModified: lastModified,
|
||||||
|
isCollection: isCollection,
|
||||||
|
usedBytes: usedBytes,
|
||||||
|
hasPreview: hasPreview,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the path of this file with the DAV part stripped
|
||||||
|
String get strippedPath {
|
||||||
|
// WebDAV path: remote.php/dav/files/{username}/{path}
|
||||||
|
if (path.contains("remote.php/dav/files")) {
|
||||||
|
return path
|
||||||
|
.substring(path.indexOf("/", "remote.php/dav/files/".length) + 1);
|
||||||
|
} else {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final String path;
|
||||||
|
final int contentLength;
|
||||||
|
final String contentType;
|
||||||
|
final String etag;
|
||||||
|
final DateTime lastModified;
|
||||||
|
final bool isCollection;
|
||||||
|
final int usedBytes;
|
||||||
|
final bool hasPreview;
|
||||||
|
// metadata
|
||||||
|
final Metadata metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileRepo {
|
||||||
|
FileRepo(this.dataSrc);
|
||||||
|
|
||||||
|
/// See [FileDataSource.list]
|
||||||
|
Future<List<File>> list(Account account, File root) =>
|
||||||
|
this.dataSrc.list(account, root);
|
||||||
|
|
||||||
|
/// See [FileDataSource.remove]
|
||||||
|
Future<void> remove(Account account, File file) =>
|
||||||
|
this.dataSrc.remove(account, file);
|
||||||
|
|
||||||
|
/// See [FileDataSource.getBinary]
|
||||||
|
Future<Uint8List> getBinary(Account account, File file) =>
|
||||||
|
this.dataSrc.getBinary(account, file);
|
||||||
|
|
||||||
|
/// See [FileDataSource.putBinary]
|
||||||
|
Future<void> putBinary(Account account, String path, Uint8List content) =>
|
||||||
|
this.dataSrc.putBinary(account, path, content);
|
||||||
|
|
||||||
|
/// See [FileDataSource.updateMetadata]
|
||||||
|
Future<void> updateMetadata(Account account, File file, Metadata metadata) =>
|
||||||
|
this.dataSrc.updateMetadata(account, file, metadata);
|
||||||
|
|
||||||
|
final FileDataSource dataSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class FileDataSource {
|
||||||
|
/// List all files under [f]
|
||||||
|
Future<List<File>> list(Account account, File f);
|
||||||
|
|
||||||
|
/// Remove file
|
||||||
|
Future<void> remove(Account account, File f);
|
||||||
|
|
||||||
|
/// Read file as binary array
|
||||||
|
Future<Uint8List> getBinary(Account account, File f);
|
||||||
|
|
||||||
|
/// Upload content to [path]
|
||||||
|
Future<void> putBinary(Account account, String path, Uint8List content);
|
||||||
|
|
||||||
|
/// Update metadata for a file
|
||||||
|
///
|
||||||
|
/// This will completely replace the metadata of the file [f]. Partial update
|
||||||
|
/// is not supported
|
||||||
|
Future<void> updateMetadata(Account account, File f, Metadata metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileWebdavDataSource implements FileDataSource {
|
||||||
|
@override
|
||||||
|
list(Account account, File f) async {
|
||||||
|
_log.fine("[list] ${f.path}");
|
||||||
|
final response = await Api(account).files().propfind(
|
||||||
|
path: f.path,
|
||||||
|
getlastmodified: 1,
|
||||||
|
resourcetype: 1,
|
||||||
|
getetag: 1,
|
||||||
|
getcontenttype: 1,
|
||||||
|
getcontentlength: 1,
|
||||||
|
hasPreview: 1,
|
||||||
|
customNamespaces: {
|
||||||
|
"com.nkming.nc_photos": "app",
|
||||||
|
},
|
||||||
|
customProperties: [
|
||||||
|
"app:metadata",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!response.isGood) {
|
||||||
|
_log.severe("[list] Failed requesting server: $response");
|
||||||
|
throw ApiException(
|
||||||
|
response: response,
|
||||||
|
message: "Failed communicating with server: ${response.statusCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
final xml = XmlDocument.parse(response.body);
|
||||||
|
final files = WebdavFileParser()(xml);
|
||||||
|
// _log.fine("[list] Parsed files: [$files]");
|
||||||
|
return files.map((e) {
|
||||||
|
if (e.metadata == null || e.metadata.fileEtag == e.etag) {
|
||||||
|
return e;
|
||||||
|
} else {
|
||||||
|
_log.info("[list] Ignore outdated metadata for ${e.path}");
|
||||||
|
return e.withoutMetadata();
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
remove(Account account, File f) async {
|
||||||
|
_log.info("[remove] ${f.path}");
|
||||||
|
final response = await Api(account).files().delete(path: f.path);
|
||||||
|
if (!response.isGood) {
|
||||||
|
_log.severe("[remove] Failed requesting server: $response");
|
||||||
|
throw ApiException(
|
||||||
|
response: response,
|
||||||
|
message: "Failed communicating with server: ${response.statusCode}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
getBinary(Account account, File f) async {
|
||||||
|
_log.info("[getBinary] ${f.path}");
|
||||||
|
final response = await Api(account).files().get(path: f.path);
|
||||||
|
if (!response.isGood) {
|
||||||
|
_log.severe("[getBinary] Failed requesting server: $response");
|
||||||
|
throw ApiException(
|
||||||
|
response: response,
|
||||||
|
message: "Failed communicating with server: ${response.statusCode}");
|
||||||
|
}
|
||||||
|
return response.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
putBinary(Account account, String path, Uint8List content) async {
|
||||||
|
_log.info("[putBinary] $path");
|
||||||
|
final response =
|
||||||
|
await Api(account).files().put(path: path, content: content);
|
||||||
|
if (!response.isGood) {
|
||||||
|
_log.severe("[putBinary] Failed requesting server: $response");
|
||||||
|
throw ApiException(
|
||||||
|
response: response,
|
||||||
|
message: "Failed communicating with server: ${response.statusCode}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
updateMetadata(Account account, File f, Metadata metadata) async {
|
||||||
|
_log.info("[updateMetadata] ${f.path}");
|
||||||
|
if (metadata != null && metadata.fileEtag != f.etag) {
|
||||||
|
_log.warning(
|
||||||
|
"[updateMetadata] etag mismatch (metadata: ${metadata.fileEtag}, file: ${f.etag})");
|
||||||
|
}
|
||||||
|
final setProps = {
|
||||||
|
if (metadata != null) "app:metadata": jsonEncode(metadata.toJson()),
|
||||||
|
};
|
||||||
|
final removeProps = [
|
||||||
|
if (metadata == null) "app:metadata",
|
||||||
|
];
|
||||||
|
final response = await Api(account).files().proppatch(
|
||||||
|
path: f.path,
|
||||||
|
namespaces: {
|
||||||
|
"com.nkming.nc_photos": "app",
|
||||||
|
},
|
||||||
|
set: setProps.isNotEmpty ? setProps : null,
|
||||||
|
remove: removeProps.isNotEmpty ? removeProps : null,
|
||||||
|
);
|
||||||
|
if (!response.isGood) {
|
||||||
|
_log.severe("[updateMetadata] Failed requesting server: $response");
|
||||||
|
throw ApiException(
|
||||||
|
response: response,
|
||||||
|
message: "Failed communicating with server: ${response.statusCode}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _log = Logger("entity.file.FileWebdavDataSource");
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileAppDbDataSource implements FileDataSource {
|
||||||
|
@override
|
||||||
|
list(Account account, File f) {
|
||||||
|
_log.info("[list] ${f.path}");
|
||||||
|
return AppDb.use((db) async {
|
||||||
|
final transaction = db.transaction(AppDb.fileStoreName, idbModeReadOnly);
|
||||||
|
final store = transaction.objectStore(AppDb.fileStoreName);
|
||||||
|
return await _doList(store, account, f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
remove(Account account, File f) {
|
||||||
|
_log.info("[remove] ${f.path}");
|
||||||
|
return AppDb.use((db) async {
|
||||||
|
final transaction = db.transaction(AppDb.fileStoreName, idbModeReadWrite);
|
||||||
|
final store = transaction.objectStore(AppDb.fileStoreName);
|
||||||
|
// we don't yet support removing dirs
|
||||||
|
// final range = KeyRange.bound(f.path, f.path + "\uffff");
|
||||||
|
await store.delete(_getCacheKey(account, f));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
getBinary(Account account, File f) {
|
||||||
|
_log.info("[getBinary] ${f.path}");
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
putBinary(Account account, String path, Uint8List content) async {
|
||||||
|
_log.info("[putBinary] $path");
|
||||||
|
// do nothing, we currently don't store file contents locally
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
updateMetadata(Account account, File f, Metadata metadata) {
|
||||||
|
_log.info("[updateMetadata] ${f.path}");
|
||||||
|
return AppDb.use((db) async {
|
||||||
|
final transaction = db.transaction(AppDb.fileStoreName, idbModeReadWrite);
|
||||||
|
final store = transaction.objectStore(AppDb.fileStoreName);
|
||||||
|
final parentDir = File(path: path.dirname(f.path));
|
||||||
|
final parentList = await _doList(store, account, parentDir);
|
||||||
|
final jsonList = parentList.map((e) {
|
||||||
|
if (e.path == f.path) {
|
||||||
|
return e.copyWith(metadata: metadata).toJson();
|
||||||
|
} else {
|
||||||
|
return e.toJson();
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
await store.put(jsonList, _getCacheKey(account, parentDir));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<File>> _doList(ObjectStore store, Account account, File f) async {
|
||||||
|
final List result = await store.getObject("${_getCacheKey(account, f)}");
|
||||||
|
if (result != null) {
|
||||||
|
return result
|
||||||
|
.cast<Map<dynamic, dynamic>>()
|
||||||
|
.map((e) => File.fromJson(e.cast()))
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
throw CacheNotFoundException("No entry: ${_getCacheKey(account, f)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _log = Logger("entity.file.FileAppDbDataSource");
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileCachedDataSource implements FileDataSource {
|
||||||
|
@override
|
||||||
|
list(Account account, File f) async {
|
||||||
|
final trimmedRootPath = f.path.trimAny("/");
|
||||||
|
List<File> cache;
|
||||||
|
try {
|
||||||
|
cache = await _appDbSrc.list(account, f);
|
||||||
|
// compare the cached root
|
||||||
|
final cacheRoot = cache.firstWhere(
|
||||||
|
(element) => element.path.trimAny("/") == trimmedRootPath,
|
||||||
|
orElse: () => null);
|
||||||
|
if (cacheRoot?.etag?.isNotEmpty == true && cacheRoot.etag == f.etag) {
|
||||||
|
// cache is good
|
||||||
|
_log.fine("[list] etag matched for ${_getCacheKey(account, f)}");
|
||||||
|
return cache;
|
||||||
|
} else {
|
||||||
|
_log.info(
|
||||||
|
"[list] Remote content updated for ${_getCacheKey(account, f)}");
|
||||||
|
}
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
// no cache
|
||||||
|
if (e is! CacheNotFoundException) {
|
||||||
|
_log.severe("[list] Cache failure", e, stacktrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// no cache
|
||||||
|
try {
|
||||||
|
final remote = await _remoteSrc.list(account, f);
|
||||||
|
await _cacheResult(account, f, remote);
|
||||||
|
if (cache != null) {
|
||||||
|
try {
|
||||||
|
await _cleanUpCachedList(account, remote, cache);
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.severe("[list] Failed while _cleanUpCachedList", e, stacktrace);
|
||||||
|
// ignore error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remote;
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (e.response.statusCode == 404) {
|
||||||
|
_log.info("[list] File removed: $f");
|
||||||
|
_appDbSrc.remove(account, f);
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
remove(Account account, File f) async {
|
||||||
|
await _appDbSrc.remove(account, f);
|
||||||
|
await _remoteSrc.remove(account, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
getBinary(Account account, File f) {
|
||||||
|
return _remoteSrc.getBinary(account, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
putBinary(Account account, String path, Uint8List content) async {
|
||||||
|
await _remoteSrc.putBinary(account, path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
updateMetadata(Account account, File f, Metadata metadata) async {
|
||||||
|
await _remoteSrc
|
||||||
|
.updateMetadata(account, f, metadata)
|
||||||
|
.then((_) => _appDbSrc.updateMetadata(account, f, metadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cacheResult(Account account, File f, List<File> result) {
|
||||||
|
return AppDb.use((db) async {
|
||||||
|
final transaction = db.transaction(AppDb.fileStoreName, idbModeReadWrite);
|
||||||
|
final store = transaction.objectStore(AppDb.fileStoreName);
|
||||||
|
await store.put(
|
||||||
|
result.map((e) => e.toJson()).toList(), _getCacheKey(account, f));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cleanUpCachedList(
|
||||||
|
Account account, List<File> remoteResults, List<File> cachedResults) {
|
||||||
|
final removed = cachedResults
|
||||||
|
.where((cache) =>
|
||||||
|
!remoteResults.any((remote) => remote.path == cache.path))
|
||||||
|
.toList();
|
||||||
|
if (removed.isEmpty) {
|
||||||
|
return Future.delayed(Duration.zero);
|
||||||
|
}
|
||||||
|
_log.info(
|
||||||
|
"[_cleanUpCachedList] Removing cache: ${removed.toReadableString()}");
|
||||||
|
return AppDb.use((db) async {
|
||||||
|
final transaction = db.transaction(AppDb.fileStoreName, idbModeReadWrite);
|
||||||
|
final store = transaction.objectStore(AppDb.fileStoreName);
|
||||||
|
for (final r in removed) {
|
||||||
|
final key = _getCacheKey(account, r);
|
||||||
|
_log.fine("[_cleanUpCachedList] Removing DB entry: $key");
|
||||||
|
// delete the dir itself
|
||||||
|
await store.delete(key);
|
||||||
|
|
||||||
|
// then its children
|
||||||
|
final range = KeyRange.bound("$key/", "$key/\uffff");
|
||||||
|
// delete with KeyRange is not supported in idb_shim/idb_sqflite
|
||||||
|
// await store.delete(range);
|
||||||
|
final keys = await store
|
||||||
|
.openKeyCursor(range: range, autoAdvance: true)
|
||||||
|
.map((cursor) => cursor.key)
|
||||||
|
.toList();
|
||||||
|
for (final k in keys) {
|
||||||
|
_log.fine("[_cleanUpCachedList] Removing DB entry: $k");
|
||||||
|
await store.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final _remoteSrc = FileWebdavDataSource();
|
||||||
|
final _appDbSrc = FileAppDbDataSource();
|
||||||
|
|
||||||
|
static final _log = Logger("entity.file.FileCachedDataSource");
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getCacheKey(Account account, File file) =>
|
||||||
|
"${account.url}/${file.path}";
|
10
lib/entity/file_util.dart
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
|
||||||
|
bool isSupportedFormat(File file) =>
|
||||||
|
_supportedFormatMimes.contains(file.contentType);
|
||||||
|
|
||||||
|
const _supportedFormatMimes = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
];
|
228
lib/entity/webdav_response_parser.dart
Normal file
|
@ -0,0 +1,228 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/string_extension.dart';
|
||||||
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
class WebdavFileParser {
|
||||||
|
List<File> call(XmlDocument xml) {
|
||||||
|
_namespaces = _parseNamespaces(xml);
|
||||||
|
final body = () {
|
||||||
|
try {
|
||||||
|
return xml.children.whereType<XmlElement>().firstWhere((element) =>
|
||||||
|
element.matchQualifiedName("multistatus",
|
||||||
|
prefix: "DAV:", namespaces: _namespaces));
|
||||||
|
} catch (_) {
|
||||||
|
_log.severe("[call] Missing element: multistatus");
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
return body.children
|
||||||
|
.whereType<XmlElement>()
|
||||||
|
.where((element) => element.matchQualifiedName("response",
|
||||||
|
prefix: "DAV:", namespaces: _namespaces))
|
||||||
|
.map((element) {
|
||||||
|
try {
|
||||||
|
return _toFile(element);
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.severe("[call] Failed parsing XML", e, stacktrace);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.where((element) => element != null)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> get namespaces => _namespaces;
|
||||||
|
|
||||||
|
Map<String, String> _parseNamespaces(XmlDocument xml) {
|
||||||
|
final namespaces = <String, String>{};
|
||||||
|
final xmlContent = xml.descendants.whereType<XmlElement>().firstWhere(
|
||||||
|
(element) => !element.name.qualified.startsWith("?"),
|
||||||
|
orElse: () => XmlElement(XmlName.fromString("")));
|
||||||
|
for (final a in xmlContent.attributes) {
|
||||||
|
if (a.name.prefix == "xmlns") {
|
||||||
|
namespaces[a.name.local] = a.value;
|
||||||
|
} else if (a.name.local == "xmlns") {
|
||||||
|
namespaces["!"] = a.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// _log.fine("[_parseNamespaces] Namespaces: $namespaces");
|
||||||
|
return namespaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map <DAV:response> contents to File
|
||||||
|
File _toFile(XmlElement element) {
|
||||||
|
String path;
|
||||||
|
int contentLength;
|
||||||
|
String contentType;
|
||||||
|
String etag;
|
||||||
|
DateTime lastModified;
|
||||||
|
bool isCollection;
|
||||||
|
int usedBytes;
|
||||||
|
bool hasPreview;
|
||||||
|
Metadata metadata;
|
||||||
|
|
||||||
|
for (final child in element.children.whereType<XmlElement>()) {
|
||||||
|
if (child.matchQualifiedName("href",
|
||||||
|
prefix: "DAV:", namespaces: _namespaces)) {
|
||||||
|
path = Uri.decodeComponent(child.innerText).trimLeftAny("/");
|
||||||
|
} else if (child.matchQualifiedName("propstat",
|
||||||
|
prefix: "DAV:", namespaces: _namespaces)) {
|
||||||
|
final status = child.children
|
||||||
|
.whereType<XmlElement>()
|
||||||
|
.firstWhere((element) => element.matchQualifiedName("status",
|
||||||
|
prefix: "DAV:", namespaces: _namespaces))
|
||||||
|
.innerText;
|
||||||
|
if (!status.contains(" 200 ")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final prop = child.children.whereType<XmlElement>().firstWhere(
|
||||||
|
(element) => element.matchQualifiedName("prop",
|
||||||
|
prefix: "DAV:", namespaces: _namespaces));
|
||||||
|
final propParser = _PropParser(namespaces: _namespaces);
|
||||||
|
propParser.parse(prop);
|
||||||
|
contentLength = propParser.contentLength;
|
||||||
|
contentType = propParser.contentType;
|
||||||
|
etag = propParser.etag;
|
||||||
|
lastModified = propParser.lastModified;
|
||||||
|
isCollection = propParser.isCollection;
|
||||||
|
usedBytes = propParser.usedBytes;
|
||||||
|
hasPreview = propParser.hasPreview;
|
||||||
|
metadata = propParser.metadata;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(
|
||||||
|
path: path,
|
||||||
|
contentLength: contentLength,
|
||||||
|
contentType: contentType,
|
||||||
|
etag: etag,
|
||||||
|
lastModified: lastModified,
|
||||||
|
isCollection: isCollection,
|
||||||
|
usedBytes: usedBytes,
|
||||||
|
hasPreview: hasPreview,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MapEntry<XmlName, dynamic> _xmlElementToMapEntry(XmlElement element) {
|
||||||
|
final key = element.name;
|
||||||
|
try {
|
||||||
|
final textNode = element.children
|
||||||
|
.where((node) => node is XmlText && node.text.trim().isNotEmpty)
|
||||||
|
.first;
|
||||||
|
return MapEntry(key, textNode.text);
|
||||||
|
} on StateError {
|
||||||
|
// No text
|
||||||
|
final value = <XmlName, dynamic>{};
|
||||||
|
for (final e in element.children.whereType<XmlElement>()) {
|
||||||
|
final entry = _xmlElementToMapEntry(e);
|
||||||
|
value[entry.key] = entry.value;
|
||||||
|
}
|
||||||
|
return MapEntry(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _namespaces = <String, String>{};
|
||||||
|
|
||||||
|
static final _log =
|
||||||
|
Logger("entity.webdav_response_parser.WebdavResponseParser");
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PropParser {
|
||||||
|
_PropParser({this.namespaces = const {}});
|
||||||
|
|
||||||
|
/// Parse <DAV:prop> element contents
|
||||||
|
void parse(XmlElement element) {
|
||||||
|
for (final child in element.children.whereType<XmlElement>()) {
|
||||||
|
if (child.matchQualifiedName("getlastmodified",
|
||||||
|
prefix: "DAV:", namespaces: namespaces)) {
|
||||||
|
_lastModified = HttpDate.parse(child.innerText);
|
||||||
|
} else if (child.matchQualifiedName("getcontentlength",
|
||||||
|
prefix: "DAV:", namespaces: namespaces)) {
|
||||||
|
_contentLength = int.parse(child.innerText);
|
||||||
|
} else if (child.matchQualifiedName("getcontenttype",
|
||||||
|
prefix: "DAV:", namespaces: namespaces)) {
|
||||||
|
_contentType = child.innerText;
|
||||||
|
} else if (child.matchQualifiedName("getetag",
|
||||||
|
prefix: "DAV:", namespaces: namespaces)) {
|
||||||
|
_etag = child.innerText.replaceAll("\"", "");
|
||||||
|
} else if (child.matchQualifiedName("quota-used-bytes",
|
||||||
|
prefix: "DAV:", namespaces: namespaces)) {
|
||||||
|
_usedBytes = int.parse(child.innerText);
|
||||||
|
} else if (child.matchQualifiedName("resourcetype",
|
||||||
|
prefix: "DAV:", namespaces: namespaces)) {
|
||||||
|
_isCollection = child.children.whereType<XmlElement>().any((element) =>
|
||||||
|
element.matchQualifiedName("collection",
|
||||||
|
prefix: "DAV:", namespaces: namespaces));
|
||||||
|
} else if (child.matchQualifiedName("has-preview",
|
||||||
|
prefix: "http://nextcloud.org/ns", namespaces: namespaces)) {
|
||||||
|
_hasPreview = child.innerText == "true";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2nd pass that depends on data in 1st pass
|
||||||
|
for (final child in element.children.whereType<XmlElement>()) {
|
||||||
|
if (child.matchQualifiedName("metadata",
|
||||||
|
prefix: "com.nkming.nc_photos", namespaces: namespaces)) {
|
||||||
|
_metadata = Metadata.fromJson(
|
||||||
|
jsonDecode(child.innerText),
|
||||||
|
upgraderV1: MetadataUpgraderV1(
|
||||||
|
fileContentType: _contentType,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime get lastModified => _lastModified;
|
||||||
|
int get contentLength => _contentLength;
|
||||||
|
String get contentType => _contentType;
|
||||||
|
String get etag => _etag;
|
||||||
|
int get usedBytes => _usedBytes;
|
||||||
|
bool get isCollection => _isCollection;
|
||||||
|
bool get hasPreview => _hasPreview;
|
||||||
|
Metadata get metadata => _metadata;
|
||||||
|
|
||||||
|
final Map<String, String> namespaces;
|
||||||
|
|
||||||
|
DateTime _lastModified;
|
||||||
|
int _contentLength;
|
||||||
|
String _contentType;
|
||||||
|
String _etag;
|
||||||
|
int _usedBytes;
|
||||||
|
bool _isCollection;
|
||||||
|
bool _hasPreview;
|
||||||
|
Metadata _metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on XmlElement {
|
||||||
|
bool matchQualifiedName(
|
||||||
|
String local, {
|
||||||
|
String prefix,
|
||||||
|
Map<String, String> namespaces,
|
||||||
|
}) {
|
||||||
|
final localNamespaces = <String, String>{};
|
||||||
|
for (final a in attributes) {
|
||||||
|
if (a.name.prefix == "xmlns") {
|
||||||
|
localNamespaces[a.name.local] = a.value;
|
||||||
|
} else if (a.name.local == "xmlns") {
|
||||||
|
localNamespaces["!"] = a.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name.local == local &&
|
||||||
|
(name.prefix == prefix ||
|
||||||
|
// match default namespace
|
||||||
|
(name.prefix == null && namespaces["!"] == prefix) ||
|
||||||
|
// match global namespace
|
||||||
|
namespaces.entries
|
||||||
|
.where((element2) => element2.value == prefix)
|
||||||
|
.any((element) => element.key == name.prefix) ||
|
||||||
|
// match local namespace
|
||||||
|
localNamespaces.entries
|
||||||
|
.where((element2) => element2.value == prefix)
|
||||||
|
.any((element) => element.key == name.prefix));
|
||||||
|
}
|
||||||
|
}
|
63
lib/event/event.dart
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:event_bus/event_bus.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/album.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
|
||||||
|
class AppEventListener<T> {
|
||||||
|
AppEventListener(this._listener);
|
||||||
|
|
||||||
|
void begin() {
|
||||||
|
if (_subscription != null) {
|
||||||
|
_log.warning("[beginListenEvent] Already listening");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_subscription = _stream.listen(_listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
void end() {
|
||||||
|
if (_subscription == null) {
|
||||||
|
_log.warning("[endListenEvent] Already not listening");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_subscription.cancel();
|
||||||
|
_subscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final void Function(T) _listener;
|
||||||
|
final _stream = KiwiContainer().resolve<EventBus>().on<T>();
|
||||||
|
StreamSubscription<T> _subscription;
|
||||||
|
|
||||||
|
final _log = Logger("event.event.AppEventListener<${T.runtimeType}>");
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumCreatedEvent {
|
||||||
|
AlbumCreatedEvent(this.account, this.album);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final Album album;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumUpdatedEvent {
|
||||||
|
AlbumUpdatedEvent(this.account, this.album);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final Album album;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileMetadataUpdatedEvent {
|
||||||
|
FileMetadataUpdatedEvent(this.account, this.file);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final File file;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileRemovedEvent {
|
||||||
|
FileRemovedEvent(this.account, this.file);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final File file;
|
||||||
|
}
|
47
lib/exception.dart
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import 'package:nc_photos/api/api.dart';
|
||||||
|
|
||||||
|
class CacheNotFoundException implements Exception {
|
||||||
|
CacheNotFoundException([this.message]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
if (message == null) {
|
||||||
|
return "CacheNotFoundException";
|
||||||
|
} else {
|
||||||
|
return "CacheNotFoundException: $message";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final dynamic message;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiException implements Exception {
|
||||||
|
ApiException({this.response, this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
if (message == null) {
|
||||||
|
return "ApiException";
|
||||||
|
} else {
|
||||||
|
return "ApiException: $message";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Response response;
|
||||||
|
final dynamic message;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PermissionException implements Exception {
|
||||||
|
PermissionException([this.message]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
if (message == null) {
|
||||||
|
return "PermissionException";
|
||||||
|
} else {
|
||||||
|
return "PermissionException: $message";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final dynamic message;
|
||||||
|
}
|
19
lib/exception_util.dart
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:nc_photos/exception.dart';
|
||||||
|
|
||||||
|
/// Convert an exception to a user-facing string
|
||||||
|
///
|
||||||
|
/// Typically used with SnackBar to show a proper error message
|
||||||
|
String toUserString(dynamic exception, BuildContext context) {
|
||||||
|
if (exception is ApiException) {
|
||||||
|
if (exception.response.statusCode == 401) {
|
||||||
|
return AppLocalizations.of(context).errorUnauthenticated;
|
||||||
|
}
|
||||||
|
} else if (exception is SocketException) {
|
||||||
|
return AppLocalizations.of(context).errorDisconnected;
|
||||||
|
}
|
||||||
|
return exception.toString();
|
||||||
|
}
|
38
lib/image_size_getter_util.dart
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:image_size_getter/image_size_getter.dart';
|
||||||
|
|
||||||
|
class AsyncMemoryInput extends AsyncImageInput {
|
||||||
|
final Uint8List bytes;
|
||||||
|
const AsyncMemoryInput(this.bytes);
|
||||||
|
|
||||||
|
factory AsyncMemoryInput.byteBuffer(ByteBuffer buffer) =>
|
||||||
|
AsyncMemoryInput(buffer.asUint8List());
|
||||||
|
|
||||||
|
@override
|
||||||
|
getRange(int start, int end) async => bytes.sublist(start, end);
|
||||||
|
|
||||||
|
@override
|
||||||
|
get length async => bytes.length;
|
||||||
|
|
||||||
|
@override
|
||||||
|
exists() async => bytes.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AsyncFileInput extends AsyncImageInput {
|
||||||
|
final File file;
|
||||||
|
|
||||||
|
AsyncFileInput(this.file);
|
||||||
|
|
||||||
|
@override
|
||||||
|
getRange(int start, int end) => file
|
||||||
|
.openRead(start, end)
|
||||||
|
.reduce((previous, element) => previous + element);
|
||||||
|
|
||||||
|
@override
|
||||||
|
get length => file.length();
|
||||||
|
|
||||||
|
@override
|
||||||
|
exists() => file.exists();
|
||||||
|
}
|
15
lib/iterable_extension.dart
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
extension IterableExtension<T> on Iterable<T> {
|
||||||
|
/// Return a new sorted list
|
||||||
|
List<T> sorted([int compare(T a, T b)]) => this.toList()..sort(compare);
|
||||||
|
|
||||||
|
/// Return a string representation of this iterable by joining the result of
|
||||||
|
/// toString for each items
|
||||||
|
String toReadableString() => "[${join(', ')}]";
|
||||||
|
|
||||||
|
Iterable<U> mapWithIndex<U>(U fn(int index, T element)) sync* {
|
||||||
|
int i = 0;
|
||||||
|
for (final e in this) {
|
||||||
|
yield fn(i++, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
17
lib/k.dart
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
/// Version string shown in settings page
|
||||||
|
const version = "4.0-f6b400";
|
||||||
|
|
||||||
|
/// Show a snack bar for a short amount of time
|
||||||
|
const snackBarDurationShort = const Duration(seconds: 4);
|
||||||
|
|
||||||
|
/// Show a snack bar for a normal amount of time
|
||||||
|
const snackBarDurationNormal = const Duration(seconds: 7);
|
||||||
|
|
||||||
|
/// Duration for short animation
|
||||||
|
const animationDurationShort = const Duration(milliseconds: 100);
|
||||||
|
|
||||||
|
/// Duration for normal animation
|
||||||
|
const animationDurationNormal = const Duration(milliseconds: 250);
|
||||||
|
|
||||||
|
/// Duration for long animation
|
||||||
|
const animationDurationLong = const Duration(milliseconds: 500);
|
351
lib/l10n/app_en.arb
Normal file
|
@ -0,0 +1,351 @@
|
||||||
|
{
|
||||||
|
"appTitle": "Photos",
|
||||||
|
"translator": "",
|
||||||
|
"@translator": {
|
||||||
|
"description": "Name of the translator(s) for this language"
|
||||||
|
},
|
||||||
|
"photosTabLabel": "Photos",
|
||||||
|
"@photosTabLabel": {
|
||||||
|
"description": "Label of the tab that lists user photos"
|
||||||
|
},
|
||||||
|
"albumsTabLabel": "Albums",
|
||||||
|
"@albumsTabLabel": {
|
||||||
|
"description": "Label of the tab that lists user albums"
|
||||||
|
},
|
||||||
|
"zoomTooltip": "Zoom",
|
||||||
|
"@zoomTooltip": {
|
||||||
|
"description": "Tooltip of the zoom button"
|
||||||
|
},
|
||||||
|
"settingsMenuLabel": "Settings",
|
||||||
|
"@settingsMenuLabel": {
|
||||||
|
"description": "Label of the settings entry in menu"
|
||||||
|
},
|
||||||
|
"selectionAppBarTitle": "{count} selected",
|
||||||
|
"@selectionAppBarTitle": {
|
||||||
|
"description": "Title of the contextual app bar that shows number of selected items",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"addSelectedToAlbumTooltip": "Add selected to album",
|
||||||
|
"@addSelectedToAlbumTooltip": {
|
||||||
|
"description": "Tooltip of the button that adds selected items to an album"
|
||||||
|
},
|
||||||
|
"addSelectedToAlbumSuccessNotification": "All items added to {album} successfully",
|
||||||
|
"@addSelectedToAlbumSuccessNotification": {
|
||||||
|
"description": "Inform user that the selected items are added to an album successfully",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"example": "Sunday Walk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"addSelectedToAlbumFailureNotification": "Failed adding items to album",
|
||||||
|
"@addSelectedToAlbumFailureNotification": {
|
||||||
|
"description": "Inform user that the selected items cannot be added to an album"
|
||||||
|
},
|
||||||
|
"addToAlbumTooltip": "Add to album",
|
||||||
|
"@addToAlbumTooltip": {
|
||||||
|
"description": "Tooltip for the add to album button"
|
||||||
|
},
|
||||||
|
"addToAlbumSuccessNotification": "Added to {album} successfully",
|
||||||
|
"@addToAlbumSuccessNotification": {
|
||||||
|
"description": "Inform user that the item is added to an album successfully",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"example": "Sunday Walk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"addToAlbumFailureNotification": "Failed adding to album",
|
||||||
|
"@addToAlbumFailureNotification": {
|
||||||
|
"description": "Inform user that the item cannot be added to an album"
|
||||||
|
},
|
||||||
|
"deleteSelectedTooltip": "Delete selected",
|
||||||
|
"@deleteSelectedTooltip": {
|
||||||
|
"description": "Tooltip of the button that deletes selected items"
|
||||||
|
},
|
||||||
|
"deleteSelectedProcessingNotification": "{count, plural, =1{Deleting 1 item} other{Deleting {count} items}}",
|
||||||
|
"@deleteSelectedProcessingNotification": {
|
||||||
|
"description": "Inform user that the selected items are being deleted",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deleteSelectedSuccessNotification": "All items deleted successfully",
|
||||||
|
"@deleteSelectedSuccessNotification": {
|
||||||
|
"description": "Inform user that the selected items are deleted successfully"
|
||||||
|
},
|
||||||
|
"deleteSelectedFailureNotification": "{count, plural, =1{Failed deleting 1 item} other{Failed deleting {count} items}}",
|
||||||
|
"@deleteSelectedFailureNotification": {
|
||||||
|
"description": "Inform user that some/all the selected items cannot be deleted",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deleteTooltip": "Delete",
|
||||||
|
"@deleteTooltip": {
|
||||||
|
"description": "Tooltip for the delete button"
|
||||||
|
},
|
||||||
|
"deleteProcessingNotification": "Deleting item",
|
||||||
|
"@deleteProcessingNotification": {
|
||||||
|
"description": "Inform user that the item is being deleted"
|
||||||
|
},
|
||||||
|
"deleteSuccessNotification": "Deleted item successfully",
|
||||||
|
"@deleteSuccessNotification": {
|
||||||
|
"description": "Inform user that the item is deleted successfully"
|
||||||
|
},
|
||||||
|
"deleteFailureNotification": "Failed deleting item",
|
||||||
|
"@deleteFailureNotification": {
|
||||||
|
"description": "Inform user that the item cannot be deleted"
|
||||||
|
},
|
||||||
|
"removeSelectedFromAlbumTooltip": "Remove selected from album",
|
||||||
|
"@removeSelectedFromAlbumTooltip": {
|
||||||
|
"description": "Tooltip of the button that remove selected items from an album"
|
||||||
|
},
|
||||||
|
"removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 item removed from album} other{{count} items removed from album}}",
|
||||||
|
"@removeSelectedFromAlbumSuccessNotification": {
|
||||||
|
"description": "Inform user that the selected items are removed from an album successfully",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"removeSelectedFromAlbumFailureNotification": "Failed removing items from album",
|
||||||
|
"@removeSelectedFromAlbumFailureNotification": {
|
||||||
|
"description": "Inform user that the selected items cannot be removed from an album"
|
||||||
|
},
|
||||||
|
"addServerTooltip": "Add server",
|
||||||
|
"@addServerTooltip": {
|
||||||
|
"description": "Tooltip of the button that adds a new server"
|
||||||
|
},
|
||||||
|
"removeServerSuccessNotification": "Removed {server} successfully",
|
||||||
|
"@removeServerSuccessNotification": {
|
||||||
|
"description": "Inform user that a server is removed successfully",
|
||||||
|
"placeholders": {
|
||||||
|
"server": {
|
||||||
|
"example": "http://www.example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"createAlbumTooltip": "Create new album",
|
||||||
|
"@createAlbumTooltip": {
|
||||||
|
"description": "Tooltip of the button that creates a new album"
|
||||||
|
},
|
||||||
|
"createAlbumFailureNotification": "Failed creating album",
|
||||||
|
"@createAlbumFailureNotification": {
|
||||||
|
"description": "Inform user that an album cannot be created"
|
||||||
|
},
|
||||||
|
"albumSize": "{count, plural, =0{Empty} =1{1 item} other{{count} items}}",
|
||||||
|
"@albumSize": {
|
||||||
|
"description": "Number of items inside an album",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connectingToServer": "Connecting to\n{server}",
|
||||||
|
"@connectingToServer": {
|
||||||
|
"description": "Inform user that the app is connecting to a server",
|
||||||
|
"placeholders": {
|
||||||
|
"server": {
|
||||||
|
"example": "http://www.example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nameInputHint": "Name",
|
||||||
|
"@nameInputHint": {
|
||||||
|
"description": "Hint of the text field expecting name data"
|
||||||
|
},
|
||||||
|
"albumNameInputInvalidEmpty": "Please enter the album name",
|
||||||
|
"@albumNameInputInvalidEmpty": {
|
||||||
|
"description": "Inform user that the album name input field cannot be empty"
|
||||||
|
},
|
||||||
|
"skipButtonLabel": "SKIP",
|
||||||
|
"@skipButtonLabel": {
|
||||||
|
"description": "Label of the skip button"
|
||||||
|
},
|
||||||
|
"confirmButtonLabel": "CONFIRM",
|
||||||
|
"@confirmButtonLabel": {
|
||||||
|
"description": "Label of the confirm button"
|
||||||
|
},
|
||||||
|
"signInHeaderText": "Sign in to Nextcloud server",
|
||||||
|
"@signInHeaderText": {
|
||||||
|
"description": "Inform user what to do in sign in widget"
|
||||||
|
},
|
||||||
|
"serverAddressInputHint": "Sever address",
|
||||||
|
"@serverAddressInputHint": {
|
||||||
|
"description": "Hint of the text field expecting server address data"
|
||||||
|
},
|
||||||
|
"serverAddressInputInvalidEmpty": "Please enter the server address",
|
||||||
|
"@serverAddressInputInvalidEmpty": {
|
||||||
|
"description": "Inform user that the server address input field cannot be empty"
|
||||||
|
},
|
||||||
|
"usernameInputHint": "Username",
|
||||||
|
"@usernameInputHint": {
|
||||||
|
"description": "Hint of the text field expecting username data"
|
||||||
|
},
|
||||||
|
"usernameInputInvalidEmpty": "Please enter your username",
|
||||||
|
"@usernameInputInvalidEmpty": {
|
||||||
|
"description": "Inform user that the username input field cannot be empty"
|
||||||
|
},
|
||||||
|
"passwordInputHint": "Password",
|
||||||
|
"@passwordInputHint": {
|
||||||
|
"description": "Hint of the text field expecting password data"
|
||||||
|
},
|
||||||
|
"passwordInputInvalidEmpty": "Please enter your password",
|
||||||
|
"@passwordInputInvalidEmpty": {
|
||||||
|
"description": "Inform user that the password input field cannot be empty"
|
||||||
|
},
|
||||||
|
"rootPickerHeaderText": "Pick the folders to be included",
|
||||||
|
"@rootPickerHeaderText": {
|
||||||
|
"description": "Inform user what to do in root picker widget"
|
||||||
|
},
|
||||||
|
"rootPickerSubHeaderText": "Only photos inside the folders will be shown. Press Skip to include all",
|
||||||
|
"@rootPickerSubHeaderText": {
|
||||||
|
"description": "Inform user what to do in root picker widget"
|
||||||
|
},
|
||||||
|
"rootPickerNavigateUpItemText": "(go back)",
|
||||||
|
"@rootPickerNavigateUpItemText": {
|
||||||
|
"description": "Text of the list item to navigate up the directory tree"
|
||||||
|
},
|
||||||
|
"setupWidgetTitle": "Getting started",
|
||||||
|
"@setupWidgetTitle": {
|
||||||
|
"description": "Title of the introductory widget"
|
||||||
|
},
|
||||||
|
"setupSettingsModifyLaterHint": "You can change this later in Settings",
|
||||||
|
"@setupSettingsModifyLaterHint": {
|
||||||
|
"description": "Inform user that they can modify this setting after the setup process"
|
||||||
|
},
|
||||||
|
"setupHiddenPrefDirNoticeDetail": "This app creates a folder on the Nextcloud server to store preference files. Please do not modify or remove it unless you plan to remove this app",
|
||||||
|
"@setupHiddenPrefDirNoticeDetail": {
|
||||||
|
"description": "Inform user about the preference folder created by this app"
|
||||||
|
},
|
||||||
|
"settingsWidgetTitle": "Settings",
|
||||||
|
"@settingsWidgetTitle": {
|
||||||
|
"description": "Title of the Settings widget"
|
||||||
|
},
|
||||||
|
"settingsExifSupportTitle": "EXIF support",
|
||||||
|
"@settingsExifSupportTitle": {
|
||||||
|
"description": "Title of the EXIF support setting"
|
||||||
|
},
|
||||||
|
"settingsExifSupportTrueSubtitle": "Require extra network usage",
|
||||||
|
"@settingsExifSupportTrueSubtitle": {
|
||||||
|
"description": "Subtitle of the EXIF support setting when the value is true. The goal is to warn user about the possible side effects of enabling this setting"
|
||||||
|
},
|
||||||
|
"settingsAboutSectionTitle": "About",
|
||||||
|
"@settingsAboutSectionTitle": {
|
||||||
|
"description": "Title of the about section in settings widget"
|
||||||
|
},
|
||||||
|
"settingsVersionTitle": "Version",
|
||||||
|
"@settingsVersionTitle": {
|
||||||
|
"description": "Title of the version data item"
|
||||||
|
},
|
||||||
|
"settingsSourceCodeTitle": "Source code",
|
||||||
|
"@settingsSourceCodeTitle": {
|
||||||
|
"description": "Title of the source code item"
|
||||||
|
},
|
||||||
|
"settingsTranslatorTitle": "Translator",
|
||||||
|
"@settingsTranslatorTitle": {
|
||||||
|
"description": "Title of the translator item"
|
||||||
|
},
|
||||||
|
"writePreferenceFailureNotification": "Failed setting preference",
|
||||||
|
"@writePreferenceFailureNotification": {
|
||||||
|
"description": "Inform user that the preference file cannot be modified"
|
||||||
|
},
|
||||||
|
"enableButtonLabel": "ENABLE",
|
||||||
|
"@enableButtonLabel": {
|
||||||
|
"description": "Label of the enable button"
|
||||||
|
},
|
||||||
|
"exifSupportDetails": "Enabling EXIF support will make various metadata available like date taken, camera model, etc. In order to read these metadata, extra network usage is required to download the original full-sized image. The app will only start downloading when connected to a Wi-Fi network",
|
||||||
|
"@exifSupportDetails": {
|
||||||
|
"description": "Detailed description of the exif support feature"
|
||||||
|
},
|
||||||
|
"exifSupportConfirmationDialogTitle": "Enable EXIF support?",
|
||||||
|
"@exifSupportConfirmationDialogTitle": {
|
||||||
|
"description": "Title of the dialog to confirm enabling exif support"
|
||||||
|
},
|
||||||
|
"doneButtonLabel": "DONE",
|
||||||
|
"@doneButtonLabel": {
|
||||||
|
"description": "Label of the done button"
|
||||||
|
},
|
||||||
|
"nextButtonLabel": "NEXT",
|
||||||
|
"@nextButtonLabel": {
|
||||||
|
"description": "Label of the next button"
|
||||||
|
},
|
||||||
|
"connectButtonLabel": "CONNECT",
|
||||||
|
"@connectButtonLabel": {
|
||||||
|
"description": "Label of the connect button"
|
||||||
|
},
|
||||||
|
"rootPickerSkipConfirmationDialogContent": "All your files will be included",
|
||||||
|
"@rootPickerSkipConfirmationDialogContent": {
|
||||||
|
"description": "Inform user what happens after skipping root picker"
|
||||||
|
},
|
||||||
|
"megapixelCount": "{count}MP",
|
||||||
|
"@megapixelCount": {
|
||||||
|
"description": "Resolution of an image in megapixel",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"example": "1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"secondCountSymbol": "{count}s",
|
||||||
|
"@secondCountSymbol": {
|
||||||
|
"description": "Number of seconds",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"millimeterCountSymbol": "{count}mm",
|
||||||
|
"@millimeterCountSymbol": {
|
||||||
|
"description": "Number of millimeters",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"detailsTooltip": "Details",
|
||||||
|
"@detailsTooltip": {
|
||||||
|
"description": "Tooltip of the details button"
|
||||||
|
},
|
||||||
|
"downloadTooltip": "Download",
|
||||||
|
"@downloadTooltip": {
|
||||||
|
"description": "Tooltip of the download button"
|
||||||
|
},
|
||||||
|
"downloadProcessingNotification": "Downloading file",
|
||||||
|
"@downloadProcessingNotification": {
|
||||||
|
"description": "Inform user that the file is being downloaded"
|
||||||
|
},
|
||||||
|
"downloadSuccessNotification": "Downloaded file successfully",
|
||||||
|
"@downloadSuccessNotification": {
|
||||||
|
"description": "Inform user that the file is downloaded successfully"
|
||||||
|
},
|
||||||
|
"downloadFailureNotification": "Failed downloading file",
|
||||||
|
"@downloadFailureNotification": {
|
||||||
|
"description": "Inform user that the file cannot be downloaded"
|
||||||
|
},
|
||||||
|
"downloadFailureNoPermissionNotification": "Require storage access permission",
|
||||||
|
"@downloadFailureNoPermissionNotification": {
|
||||||
|
"description": "Inform user that the file cannot be downloaded due to missing storage permission"
|
||||||
|
},
|
||||||
|
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
|
||||||
|
"@errorUnauthenticated": {
|
||||||
|
"description": "Error message when server responds with HTTP401"
|
||||||
|
},
|
||||||
|
"errorDisconnected": "Unable to connect. Server may be offline or your device may be disconnected",
|
||||||
|
"@errorDisconnected": {
|
||||||
|
"description": "Error message when the app can't connect to the server"
|
||||||
|
}
|
||||||
|
}
|
9
lib/list_extension.dart
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
extension ListExtension<T> on List<T> {
|
||||||
|
/// Return a new list with only distinct elements
|
||||||
|
List<T> distinct() {
|
||||||
|
final s = Set();
|
||||||
|
return this.where((element) => s.add(element)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Iterable<T> takeIndex(List<int> indexes) => indexes.map((e) => this[e]);
|
||||||
|
}
|
78
lib/main.dart
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:event_bus/event_bus.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/metadata_task_manager.dart';
|
||||||
|
import 'package:nc_photos/mobile/platform.dart'
|
||||||
|
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
|
||||||
|
import 'package:nc_photos/pref.dart';
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
_initLog();
|
||||||
|
await _initPref();
|
||||||
|
_initBloc();
|
||||||
|
_initKiwi();
|
||||||
|
_initEquatable();
|
||||||
|
|
||||||
|
runApp(platform.MyApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initLog() {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrintGestureArenaDiagnostics = true;
|
||||||
|
}
|
||||||
|
Logger.root.level = Level.ALL;
|
||||||
|
Logger.root.onRecord.listen((record) {
|
||||||
|
// dev.log(
|
||||||
|
// "${record.level.name} ${record.time}: ${record.message}",
|
||||||
|
// time: record.time,
|
||||||
|
// sequenceNumber: record.sequenceNumber,
|
||||||
|
// level: record.level.value,
|
||||||
|
// name: record.loggerName,
|
||||||
|
// );
|
||||||
|
if (kReleaseMode && record.level <= Level.FINE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String msg =
|
||||||
|
"[${record.loggerName}] ${record.level.name} ${record.time}: ${record.message}";
|
||||||
|
if (record.error != null) {
|
||||||
|
msg += " (throw: ${record.error.runtimeType} { ${record.error} })";
|
||||||
|
}
|
||||||
|
if (record.stackTrace != null) {
|
||||||
|
msg += "\nStack Trace:\n${record.stackTrace}";
|
||||||
|
}
|
||||||
|
debugPrint(msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initPref() => Pref.init();
|
||||||
|
|
||||||
|
void _initBloc() {
|
||||||
|
Bloc.observer = _BlocObserver();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initKiwi() {
|
||||||
|
final kiwi = KiwiContainer();
|
||||||
|
kiwi.registerInstance<EventBus>(EventBus());
|
||||||
|
kiwi.registerInstance<MetadataTaskManager>(MetadataTaskManager());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initEquatable() {
|
||||||
|
EquatableConfig.stringify = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BlocObserver extends BlocObserver {
|
||||||
|
@override
|
||||||
|
onChange(BlocBase bloc, Change change) {
|
||||||
|
super.onChange(bloc, change);
|
||||||
|
_log.finer("${bloc.runtimeType} $change");
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _log = Logger("_BlocObserver");
|
||||||
|
}
|
67
lib/metadata_task_manager.dart
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/pref.dart';
|
||||||
|
import 'package:nc_photos/use_case/update_missing_metadata.dart';
|
||||||
|
|
||||||
|
/// Task to update metadata for missing files
|
||||||
|
class MetadataTask {
|
||||||
|
MetadataTask(this.account);
|
||||||
|
|
||||||
|
@override
|
||||||
|
toString() {
|
||||||
|
return "$runtimeType {"
|
||||||
|
"account: $account, "
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> call() async {
|
||||||
|
final fileRepo = FileRepo(FileCachedDataSource());
|
||||||
|
for (final r in account.roots) {
|
||||||
|
final op = UpdateMissingMetadata(fileRepo);
|
||||||
|
await for (final _ in op(account,
|
||||||
|
File(path: "${api_util.getWebdavRootUrlRelative(account)}/$r"))) {
|
||||||
|
if (!Pref.inst().isEnableExif()) {
|
||||||
|
_log.info("[call] EXIF disabled, task ending immaturely");
|
||||||
|
op.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
|
||||||
|
static final _log = Logger("metadata_task_manager.MetadataTask");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manage metadata tasks to run concurrently
|
||||||
|
class MetadataTaskManager {
|
||||||
|
MetadataTaskManager() {
|
||||||
|
_handleStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a task to the queue
|
||||||
|
void addTask(MetadataTask task) {
|
||||||
|
_log.info("[addTask] New task added: $task");
|
||||||
|
_streamController.add(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleStream() async {
|
||||||
|
await for (final task in _streamController.stream) {
|
||||||
|
if (Pref.inst().isEnableExif()) {
|
||||||
|
_log.info("[_doTask] Executing task: $task");
|
||||||
|
await task();
|
||||||
|
} else {
|
||||||
|
_log.info("[_doTask] Ignoring task: $task");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _streamController = StreamController<MetadataTask>.broadcast();
|
||||||
|
|
||||||
|
static final _log = Logger("metadata_task_manager.MetadataTaskManager");
|
||||||
|
}
|
18
lib/mobile/android/media_store.dart
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class MediaStore {
|
||||||
|
static const exceptionCodePermissionError = "permissionError";
|
||||||
|
|
||||||
|
static Future<void> saveFileToDownload(
|
||||||
|
String fileName, Uint8List fileContent) async {
|
||||||
|
await _channel.invokeMethod("saveFileToDownload", <String, dynamic>{
|
||||||
|
"fileName": fileName,
|
||||||
|
"content": fileContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _channel =
|
||||||
|
const MethodChannel("com.nkming.nc_photos/media_store");
|
||||||
|
}
|
35
lib/mobile/downloader.dart
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/exception.dart';
|
||||||
|
import 'package:nc_photos/mobile/android/media_store.dart';
|
||||||
|
import 'package:nc_photos/platform/downloader.dart' as itf;
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
|
class Downloader extends itf.Downloader {
|
||||||
|
@override
|
||||||
|
downloadFile(Account account, File file) {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return _downloadFileAndroid(account, file);
|
||||||
|
} else {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadFileAndroid(Account account, File file) async {
|
||||||
|
final fileRepo = FileRepo(FileCachedDataSource());
|
||||||
|
final fileContent = await fileRepo.getBinary(account, file);
|
||||||
|
try {
|
||||||
|
await MediaStore.saveFileToDownload(
|
||||||
|
path.basename(file.path), fileContent);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
if (e.code == MediaStore.exceptionCodePermissionError) {
|
||||||
|
throw PermissionException();
|
||||||
|
} else {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
92
lib/mobile/metadata_loader.dart
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:exifdart/exifdart_io.dart';
|
||||||
|
import 'package:exifdart/exifdart_memory.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:image_size_getter/image_size_getter.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api.dart';
|
||||||
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||||
|
import 'package:nc_photos/cache_manager_util.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/exception.dart';
|
||||||
|
import 'package:nc_photos/image_size_getter_util.dart';
|
||||||
|
import 'package:nc_photos/platform/metadata_loader.dart' as itf;
|
||||||
|
|
||||||
|
class MetadataLoader implements itf.MetadataLoader {
|
||||||
|
@override
|
||||||
|
loadCacheFile(Account account, File file) async {
|
||||||
|
final getFileFuture =
|
||||||
|
_getFileTask.getFileUntil(api_util.getFileUrl(account, file));
|
||||||
|
final result = await Future.any([
|
||||||
|
getFileFuture,
|
||||||
|
Future.delayed(Duration(seconds: 10)),
|
||||||
|
]);
|
||||||
|
if (_getFileTask.isGood && result is FileInfo) {
|
||||||
|
return _onGetFile(result);
|
||||||
|
} else {
|
||||||
|
// timeout
|
||||||
|
_getFileTask.cancel();
|
||||||
|
throw TimeoutException("Timeout loading file: ${file.strippedPath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
loadNewFile(Account account, File file) async {
|
||||||
|
final response =
|
||||||
|
await Api(account).files().get(path: api_util.getFileUrlRelative(file));
|
||||||
|
if (!response.isGood) {
|
||||||
|
_log.severe("[forceLoadFile] Failed requesting server: $response");
|
||||||
|
throw ApiException(
|
||||||
|
response: response,
|
||||||
|
message: "Failed communicating with server: ${response.statusCode}");
|
||||||
|
}
|
||||||
|
final resolution =
|
||||||
|
await AsyncImageSizeGetter.getSize(AsyncMemoryInput(response.body));
|
||||||
|
final exif = await readExifFromBytes(response.body);
|
||||||
|
return {
|
||||||
|
if (exif != null) "exif": exif,
|
||||||
|
if (resolution.width > 0 && resolution.height > 0)
|
||||||
|
"resolution": {
|
||||||
|
"width": resolution.width,
|
||||||
|
"height": resolution.height,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
loadFile(Account account, File file) async {
|
||||||
|
final store = DefaultCacheManager().store;
|
||||||
|
final info = await store.getFile(api_util.getFileUrl(account, file));
|
||||||
|
if (info == null) {
|
||||||
|
// no cache
|
||||||
|
return loadNewFile(account, file);
|
||||||
|
} else {
|
||||||
|
return _onGetFile(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
cancel() {
|
||||||
|
_getFileTask.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _onGetFile(FileInfo f) async {
|
||||||
|
final resolution =
|
||||||
|
await AsyncImageSizeGetter.getSize(AsyncFileInput(f.file));
|
||||||
|
final exif = await readExifFromFile(f.file);
|
||||||
|
return {
|
||||||
|
if (exif != null) "exif": exif,
|
||||||
|
if (resolution.width > 0 && resolution.height > 0)
|
||||||
|
"resolution": {
|
||||||
|
"width": resolution.width,
|
||||||
|
"height": resolution.height,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
final _getFileTask = CancelableGetFile(DefaultCacheManager().store);
|
||||||
|
|
||||||
|
static final _log = Logger("mobile.metadata_loader.MetadataLoader");
|
||||||
|
}
|
7
lib/mobile/my_app.dart
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import 'package:idb_sqflite/idb_sqflite.dart';
|
||||||
|
import 'package:nc_photos/widget/my_app.dart' as itf;
|
||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
|
class MyApp extends itf.MyApp {
|
||||||
|
static IdbFactory getDbFactory() => getIdbFactorySqflite(databaseFactory);
|
||||||
|
}
|
3
lib/mobile/platform.dart
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export 'downloader.dart';
|
||||||
|
export 'metadata_loader.dart';
|
||||||
|
export 'my_app.dart';
|
7
lib/platform/downloader.dart
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
|
||||||
|
abstract class Downloader {
|
||||||
|
/// Download file to device
|
||||||
|
Future<void> downloadFile(Account account, File file);
|
||||||
|
}
|
21
lib/platform/metadata_loader.dart
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
|
||||||
|
abstract class MetadataLoader {
|
||||||
|
/// Load metadata for [file] from cache
|
||||||
|
///
|
||||||
|
/// If the file is not found in cache after a certain amount of time, an
|
||||||
|
/// exception will be thrown
|
||||||
|
Future<Map<String, dynamic>> loadCacheFile(Account account, File file);
|
||||||
|
|
||||||
|
/// Download and load metadata for [file]
|
||||||
|
///
|
||||||
|
/// This function will always try to download the file, no matter it's cached
|
||||||
|
/// or not
|
||||||
|
Future<Map<String, dynamic>> loadNewFile(Account account, File file);
|
||||||
|
|
||||||
|
/// Load metadata for [file], either from cache or a new download
|
||||||
|
Future<Map<String, dynamic>> loadFile(Account account, File file);
|
||||||
|
|
||||||
|
void cancel();
|
||||||
|
}
|
68
lib/pref.dart
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class Pref {
|
||||||
|
static Future<void> init() async {
|
||||||
|
return SharedPreferences.getInstance().then((pref) {
|
||||||
|
_inst._pref = pref;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Pref.inst() => _inst;
|
||||||
|
|
||||||
|
List<Account> getAccounts([List<Account> def]) {
|
||||||
|
final jsonObjs = _pref.getStringList("accounts");
|
||||||
|
return jsonObjs?.map((e) => Account.fromJson(jsonDecode(e)))?.toList() ??
|
||||||
|
def;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> setAccounts(List<Account> value) {
|
||||||
|
final jsons = value.map((e) => jsonEncode(e.toJson())).toList();
|
||||||
|
return _pref.setStringList("accounts", jsons);
|
||||||
|
}
|
||||||
|
|
||||||
|
int getCurrentAccountIndex([int def]) =>
|
||||||
|
_pref.getInt("currentAccountIndex") ?? def;
|
||||||
|
|
||||||
|
Future<bool> setCurrentAccountIndex(int value) =>
|
||||||
|
_pref.setInt("currentAccountIndex", value);
|
||||||
|
|
||||||
|
int getHomePhotosZoomLevel([int def]) =>
|
||||||
|
_pref.getInt("homePhotosZoomLevel") ?? def;
|
||||||
|
|
||||||
|
Future<bool> setHomePhotosZoomLevel(int value) =>
|
||||||
|
_pref.setInt("homePhotosZoomLevel", value);
|
||||||
|
|
||||||
|
int getAlbumViewerZoomLevel([int def]) =>
|
||||||
|
_pref.getInt("albumViewerZoomLevel") ?? def;
|
||||||
|
|
||||||
|
Future<bool> setAlbumViewerZoomLevel(int value) =>
|
||||||
|
_pref.setInt("albumViewerZoomLevel", value);
|
||||||
|
|
||||||
|
bool isEnableExif([bool def = true]) => _pref.getBool("isEnableExif") ?? def;
|
||||||
|
|
||||||
|
Future<bool> setEnableExif(bool value) =>
|
||||||
|
_pref.setBool("isEnableExif", value);
|
||||||
|
|
||||||
|
int getSetupProgress([int def = 0]) => _pref.getInt("setupProgress") ?? def;
|
||||||
|
|
||||||
|
Future<bool> setSetupProgress(int value) =>
|
||||||
|
_pref.setInt("setupProgress", value);
|
||||||
|
|
||||||
|
Pref._();
|
||||||
|
|
||||||
|
static final _inst = Pref._();
|
||||||
|
SharedPreferences _pref;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PrefExtension on Pref {
|
||||||
|
Account getCurrentAccount() {
|
||||||
|
try {
|
||||||
|
return Pref.inst().getAccounts()[Pref.inst().getCurrentAccountIndex()];
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
47
lib/snack_bar_manager.dart
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
/// Showing snack bars
|
||||||
|
///
|
||||||
|
/// This manager helps showing a snack bar even after the context was
|
||||||
|
/// invalidated by having another widget (presumably top-level) to handle such
|
||||||
|
/// request in a decoupled way
|
||||||
|
class SnackBarManager {
|
||||||
|
factory SnackBarManager() => _inst;
|
||||||
|
|
||||||
|
SnackBarManager._();
|
||||||
|
|
||||||
|
void registerHandler(SnackBarHandler handler) {
|
||||||
|
_handlers.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
void unregisterHandler(SnackBarHandler handler) {
|
||||||
|
_handlers.remove(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show a snack bar if possible
|
||||||
|
///
|
||||||
|
/// If the snack bar can't be shown at this time, return null
|
||||||
|
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showSnackBar(
|
||||||
|
SnackBar snackBar) {
|
||||||
|
for (final h in _handlers.reversed) {
|
||||||
|
final result = h.showSnackBar(snackBar);
|
||||||
|
if (result != null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_log.warning("[showSnackBar] No handler available");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final _handlers = <SnackBarHandler>[];
|
||||||
|
|
||||||
|
static final _inst = SnackBarManager._();
|
||||||
|
|
||||||
|
final _log = Logger("snack_bar_manager.SnackBarManager");
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class SnackBarHandler {
|
||||||
|
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showSnackBar(
|
||||||
|
SnackBar snackBar);
|
||||||
|
}
|
26
lib/string_extension.dart
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
extension StringExtension on String {
|
||||||
|
/// Returns the string without any leading characters included in [characters]
|
||||||
|
String trimLeftAny(String characters) {
|
||||||
|
int i = 0;
|
||||||
|
while (i < length && characters.contains(this[i])) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return this.substring(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the string without any trailing characters included in
|
||||||
|
/// [characters]
|
||||||
|
String trimRightAny(String characters) {
|
||||||
|
int i = 0;
|
||||||
|
while (i < length && characters.contains(this[length - 1 - i])) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return this.substring(0, length - i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the string without any leading and trailing characters included in
|
||||||
|
/// [characters]
|
||||||
|
String trimAny(String characters) {
|
||||||
|
return trimLeftAny(characters).trimRightAny(characters);
|
||||||
|
}
|
||||||
|
}
|
100
lib/theme.dart
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class AppTheme extends StatelessWidget {
|
||||||
|
const AppTheme({@required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Theme(
|
||||||
|
data: theme.brightness == Brightness.light
|
||||||
|
? _buildLightThemeData(context, theme)
|
||||||
|
: _buildDarkThemeData(context, theme),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeData _buildLightThemeData(BuildContext context, ThemeData theme) {
|
||||||
|
final appBarTheme = theme.appBarTheme.copyWith(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
color: theme.scaffoldBackgroundColor,
|
||||||
|
actionsIconTheme: theme.primaryIconTheme.copyWith(color: Colors.black),
|
||||||
|
iconTheme: theme.primaryIconTheme.copyWith(color: Colors.black),
|
||||||
|
textTheme: theme.primaryTextTheme.apply(bodyColor: Colors.black),
|
||||||
|
);
|
||||||
|
return theme.copyWith(appBarTheme: appBarTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeData _buildDarkThemeData(BuildContext context, ThemeData theme) {
|
||||||
|
final appBarTheme = theme.appBarTheme.copyWith(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
color: theme.scaffoldBackgroundColor,
|
||||||
|
actionsIconTheme: theme.primaryIconTheme.copyWith(color: Colors.white),
|
||||||
|
iconTheme: theme.primaryIconTheme.copyWith(color: Colors.white),
|
||||||
|
textTheme: theme.primaryTextTheme.apply(bodyColor: Colors.white),
|
||||||
|
);
|
||||||
|
return theme.copyWith(appBarTheme: appBarTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppBarTheme getContextualAppBarTheme(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
if (theme.brightness == Brightness.light) {
|
||||||
|
return theme.appBarTheme.copyWith(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
color: Colors.grey[800],
|
||||||
|
actionsIconTheme: theme.primaryIconTheme.copyWith(color: Colors.white),
|
||||||
|
iconTheme: theme.primaryIconTheme.copyWith(color: Colors.white),
|
||||||
|
textTheme: theme.primaryTextTheme.apply(bodyColor: Colors.white),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return theme.appBarTheme.copyWith(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
color: Colors.grey[200],
|
||||||
|
actionsIconTheme: theme.primaryIconTheme.copyWith(color: Colors.black),
|
||||||
|
iconTheme: theme.primaryIconTheme.copyWith(color: Colors.black),
|
||||||
|
textTheme: theme.primaryTextTheme.apply(bodyColor: Colors.black),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color getSelectionOverlayColor(BuildContext context) {
|
||||||
|
return Theme.of(context).brightness == Brightness.light
|
||||||
|
? primarySwatchLight[100].withOpacity(0.7)
|
||||||
|
: primarySwatchDark[100].withOpacity(0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color getSelectionCheckColor(BuildContext context) {
|
||||||
|
return Theme.of(context).brightness == Brightness.light
|
||||||
|
? Colors.grey[800]
|
||||||
|
: Colors.grey[200];
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color getOverscrollIndicatorColor(BuildContext context) {
|
||||||
|
return Theme.of(context).brightness == Brightness.light
|
||||||
|
? Colors.grey[800]
|
||||||
|
: Colors.grey[200];
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color getRootPickerContentBoxColor(BuildContext context) {
|
||||||
|
return Colors.blue[200];
|
||||||
|
}
|
||||||
|
|
||||||
|
static const primarySwatchLight = Colors.blue;
|
||||||
|
static const primarySwatchDark = Colors.cyan;
|
||||||
|
|
||||||
|
static const widthLimitedContentMaxWidth = 550.0;
|
||||||
|
|
||||||
|
/// Make a TextButton look like a default FlatButton. See
|
||||||
|
/// https://flutter.dev/go/material-button-migration-guide
|
||||||
|
static final flatButtonStyle = TextButton.styleFrom(
|
||||||
|
primary: Colors.black87,
|
||||||
|
minimumSize: Size(88, 36),
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(2.0)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
}
|
19
lib/use_case/create_album.dart
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import 'package:event_bus/event_bus.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/album.dart';
|
||||||
|
import 'package:nc_photos/event/event.dart';
|
||||||
|
|
||||||
|
class CreateAlbum {
|
||||||
|
CreateAlbum(this.albumRepo);
|
||||||
|
|
||||||
|
Future<Album> call(Account account, Album album) async {
|
||||||
|
final newAlbum = await albumRepo.create(account, album);
|
||||||
|
KiwiContainer()
|
||||||
|
.resolve<EventBus>()
|
||||||
|
.fire(AlbumCreatedEvent(account, newAlbum));
|
||||||
|
return newAlbum;
|
||||||
|
}
|
||||||
|
|
||||||
|
final AlbumRepo albumRepo;
|
||||||
|
}
|
14
lib/use_case/get_file_binary.dart
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
|
||||||
|
class GetFileBinary {
|
||||||
|
GetFileBinary(this.fileRepo);
|
||||||
|
|
||||||
|
/// Get the binary content of a file
|
||||||
|
Future<Uint8List> call(Account account, File file) =>
|
||||||
|
fileRepo.getBinary(account, file);
|
||||||
|
|
||||||
|
final FileRepo fileRepo;
|
||||||
|
}
|
44
lib/use_case/list_album.dart
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/album.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/exception.dart';
|
||||||
|
import 'package:nc_photos/use_case/ls.dart';
|
||||||
|
|
||||||
|
class ListAlbum {
|
||||||
|
ListAlbum(this.fileRepo, this.albumRepo);
|
||||||
|
|
||||||
|
/// List all albums associated with [account]
|
||||||
|
Future<List<Album>> call(Account account) async {
|
||||||
|
try {
|
||||||
|
final albumFiles = await Ls(fileRepo)(
|
||||||
|
account,
|
||||||
|
File(
|
||||||
|
path: getAlbumFileRoot(account),
|
||||||
|
));
|
||||||
|
final albums = <Album>[];
|
||||||
|
for (final f in albumFiles) {
|
||||||
|
final album = await albumRepo.get(account, f);
|
||||||
|
albums.add(album);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
albumRepo.cleanUp(account, albumFiles);
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
// not important, log and ignore
|
||||||
|
_log.severe("[call] Failed while cleanUp", e, stacktrace);
|
||||||
|
}
|
||||||
|
return albums;
|
||||||
|
} catch (e) {
|
||||||
|
if (e is ApiException && e.response.statusCode == 404) {
|
||||||
|
// no albums
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final FileRepo fileRepo;
|
||||||
|
final AlbumRepo albumRepo;
|
||||||
|
|
||||||
|
static final _log = Logger("use_case.list_album.ListAlbum");
|
||||||
|
}
|
27
lib/use_case/ls.dart
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/string_extension.dart';
|
||||||
|
|
||||||
|
class Ls {
|
||||||
|
Ls(this.fileRepo);
|
||||||
|
|
||||||
|
/// List all files under a dir
|
||||||
|
///
|
||||||
|
/// The resulting list would normally also include the [root] dir. If
|
||||||
|
/// [shouldExcludeRootDir] == true, such entry will be removed
|
||||||
|
Future<List<File>> call(Account account, File root,
|
||||||
|
{bool shouldExcludeRootDir = true}) async {
|
||||||
|
final products = await fileRepo.list(account, root);
|
||||||
|
if (shouldExcludeRootDir) {
|
||||||
|
// filter out root file
|
||||||
|
final trimmedRootPath = root.path.trimAny("/");
|
||||||
|
return products
|
||||||
|
.where((element) => element.path.trimAny("/") != trimmedRootPath)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
return products;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final FileRepo fileRepo;
|
||||||
|
}
|
14
lib/use_case/put_file_binary.dart
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
|
||||||
|
class PutFileBinary {
|
||||||
|
PutFileBinary(this.fileRepo);
|
||||||
|
|
||||||
|
/// Upload file to [path]
|
||||||
|
Future<void> call(Account account, String path, Uint8List content) =>
|
||||||
|
fileRepo.putBinary(account, path, content);
|
||||||
|
|
||||||
|
final FileRepo fileRepo;
|
||||||
|
}
|
48
lib/use_case/remove.dart
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import 'package:event_bus/event_bus.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/album.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/event/event.dart';
|
||||||
|
import 'package:nc_photos/use_case/list_album.dart';
|
||||||
|
import 'package:nc_photos/use_case/update_album.dart';
|
||||||
|
|
||||||
|
class Remove {
|
||||||
|
Remove(this.fileRepo, this.albumRepo);
|
||||||
|
|
||||||
|
/// Remove a file
|
||||||
|
Future<void> call(Account account, File file) async {
|
||||||
|
await fileRepo.remove(account, file);
|
||||||
|
await _cleanUpAlbums(account, file);
|
||||||
|
KiwiContainer().resolve<EventBus>().fire(FileRemovedEvent(account, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cleanUpAlbums(Account account, File file) async {
|
||||||
|
final albums = await ListAlbum(fileRepo, albumRepo)(account);
|
||||||
|
for (final a in albums) {
|
||||||
|
try {
|
||||||
|
if (a.items.any((element) =>
|
||||||
|
element is AlbumFileItem && element.file.path == file.path)) {
|
||||||
|
final newItems = a.items.where((element) {
|
||||||
|
if (element is AlbumFileItem) {
|
||||||
|
return element.file.path != file.path;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
await UpdateAlbum(albumRepo)(account, a.copyWith(items: newItems));
|
||||||
|
}
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.severe(
|
||||||
|
"[_cleanUpAlbums] Failed while updating album", e, stacktrace);
|
||||||
|
// continue to next album
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final FileRepo fileRepo;
|
||||||
|
final AlbumRepo albumRepo;
|
||||||
|
|
||||||
|
static final _log = Logger("use_case.remove.Remove");
|
||||||
|
}
|
44
lib/use_case/scan_dir.dart
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/use_case/ls.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
|
class ScanDir {
|
||||||
|
ScanDir(this.fileRepo);
|
||||||
|
|
||||||
|
/// List all files under a dir recursively
|
||||||
|
///
|
||||||
|
/// Dirs with a .nomedia/.noimage file will be ignored. The returned stream
|
||||||
|
/// would emit either List<File> data or an exception
|
||||||
|
Stream<dynamic> call(Account account, File root) async* {
|
||||||
|
try {
|
||||||
|
final items = await Ls(fileRepo)(account, root);
|
||||||
|
if (_shouldScanIgnoreDir(items)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield items.where((element) => element.isCollection != true).toList();
|
||||||
|
for (final i in items.where((element) => element.isCollection == true)) {
|
||||||
|
yield* this(account, i);
|
||||||
|
}
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.severe("[call] Failed scanning \"${root.path}\"", e, stacktrace);
|
||||||
|
// for some reason exception thrown here can't be caught outside
|
||||||
|
// rethrow;
|
||||||
|
yield e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return if this dir should be ignored in a scan op based on files under
|
||||||
|
/// this dir
|
||||||
|
static bool _shouldScanIgnoreDir(Iterable<File> files) {
|
||||||
|
return files.any((element) {
|
||||||
|
final basename = path.basename(element.path);
|
||||||
|
return basename == ".nomedia" || basename == ".noimage";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final FileRepo fileRepo;
|
||||||
|
|
||||||
|
static final _log = Logger("use_case.scan_dir.ScanDir");
|
||||||
|
}
|
30
lib/use_case/scan_missing_metadata.dart
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
|
import 'package:nc_photos/use_case/scan_dir.dart';
|
||||||
|
|
||||||
|
class ScanMissingMetadata {
|
||||||
|
ScanMissingMetadata(this.fileRepo);
|
||||||
|
|
||||||
|
/// List all files that support metadata but yet having one under a dir
|
||||||
|
/// recursively
|
||||||
|
///
|
||||||
|
/// Dirs with a .nomedia/.noimage file will be ignored. The returned stream
|
||||||
|
/// would emit either File data or an exception
|
||||||
|
Stream<dynamic> call(Account account, File root) async* {
|
||||||
|
final dataStream = ScanDir(fileRepo)(account, root);
|
||||||
|
await for (final d in dataStream) {
|
||||||
|
if (d is Exception || d is Error) {
|
||||||
|
yield d;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final missingMetadata = (d as List<File>).where((element) =>
|
||||||
|
file_util.isSupportedFormat(element) && element.metadata == null);
|
||||||
|
for (final f in missingMetadata) {
|
||||||
|
yield f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final FileRepo fileRepo;
|
||||||
|
}
|
16
lib/use_case/update_album.dart
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import 'package:event_bus/event_bus.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/album.dart';
|
||||||
|
import 'package:nc_photos/event/event.dart';
|
||||||
|
|
||||||
|
class UpdateAlbum {
|
||||||
|
UpdateAlbum(this.albumRepo);
|
||||||
|
|
||||||
|
Future<void> call(Account account, Album album) async {
|
||||||
|
await albumRepo.update(account, album);
|
||||||
|
KiwiContainer().resolve<EventBus>().fire(AlbumUpdatedEvent(account, album));
|
||||||
|
}
|
||||||
|
|
||||||
|
final AlbumRepo albumRepo;
|
||||||
|
}
|
56
lib/use_case/update_metadata.dart
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import 'package:event_bus/event_bus.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/album.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/event/event.dart';
|
||||||
|
import 'package:nc_photos/use_case/list_album.dart';
|
||||||
|
import 'package:nc_photos/use_case/update_album.dart';
|
||||||
|
|
||||||
|
class UpdateMetadata {
|
||||||
|
UpdateMetadata(this.fileRepo, this.albumRepo);
|
||||||
|
|
||||||
|
Future<void> call(Account account, File file, Metadata metadata) async {
|
||||||
|
if (file.etag != metadata.fileEtag) {
|
||||||
|
_log.warning(
|
||||||
|
"[call] Metadata fileEtag mismatch with actual file's (metadata: ${metadata.fileEtag}, file: ${file.etag})");
|
||||||
|
}
|
||||||
|
await fileRepo.updateMetadata(account, file, metadata);
|
||||||
|
await _cleanUpAlbums(account, file, metadata);
|
||||||
|
KiwiContainer()
|
||||||
|
.resolve<EventBus>()
|
||||||
|
.fire(FileMetadataUpdatedEvent(account, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cleanUpAlbums(
|
||||||
|
Account account, File file, Metadata metadata) async {
|
||||||
|
final albums = await ListAlbum(fileRepo, albumRepo)(account);
|
||||||
|
for (final a in albums) {
|
||||||
|
try {
|
||||||
|
if (a.items.any((element) =>
|
||||||
|
element is AlbumFileItem && element.file.path == file.path)) {
|
||||||
|
final newItems = a.items.map((e) {
|
||||||
|
if (e is AlbumFileItem && e.file.path == file.path) {
|
||||||
|
return AlbumFileItem(
|
||||||
|
file: e.file.copyWith(metadata: metadata),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
await UpdateAlbum(albumRepo)(account, a.copyWith(items: newItems));
|
||||||
|
}
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.severe(
|
||||||
|
"[_cleanUpAlbums] Failed while updating album", e, stacktrace);
|
||||||
|
// continue to next album
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final FileRepo fileRepo;
|
||||||
|
final AlbumRepo albumRepo;
|
||||||
|
|
||||||
|
static final _log = Logger("use_case.update_metadata.UpdateMetadata");
|
||||||
|
}
|
73
lib/use_case/update_missing_metadata.dart
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/connectivity_util.dart' as connectivity_util;
|
||||||
|
import 'package:nc_photos/entity/album.dart';
|
||||||
|
import 'package:nc_photos/entity/exif.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/mobile/platform.dart'
|
||||||
|
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
|
||||||
|
import 'package:nc_photos/use_case/scan_missing_metadata.dart';
|
||||||
|
import 'package:nc_photos/use_case/update_metadata.dart';
|
||||||
|
|
||||||
|
class UpdateMissingMetadata {
|
||||||
|
UpdateMissingMetadata(this.fileRepo);
|
||||||
|
|
||||||
|
/// Update metadata for all files that support one under a dir recursively
|
||||||
|
///
|
||||||
|
/// Dirs with a .nomedia/.noimage file will be ignored. The returned stream
|
||||||
|
/// would emit either File data (for each updated files) or an exception
|
||||||
|
Stream<dynamic> call(Account account, File root) async* {
|
||||||
|
final dataStream = ScanMissingMetadata(fileRepo)(account, root);
|
||||||
|
final metadataLoader = platform.MetadataLoader();
|
||||||
|
await for (final d in dataStream) {
|
||||||
|
if (d is Exception || d is Error) {
|
||||||
|
yield d;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// since we need to download multiple images in their original size,
|
||||||
|
// we only do it with WiFi
|
||||||
|
await connectivity_util.waitUntilWifi();
|
||||||
|
if (!shouldRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final File file = d;
|
||||||
|
_log.fine("[call] Updating metadata for ${file.path}");
|
||||||
|
final metadata = await metadataLoader.loadFile(account, file);
|
||||||
|
int imageWidth, imageHeight;
|
||||||
|
Exif exif;
|
||||||
|
if (metadata.containsKey("resolution")) {
|
||||||
|
imageWidth = metadata["resolution"]["width"];
|
||||||
|
imageHeight = metadata["resolution"]["height"];
|
||||||
|
}
|
||||||
|
if (metadata.containsKey("exif")) {
|
||||||
|
exif = Exif(metadata["exif"]);
|
||||||
|
}
|
||||||
|
final metadataObj = Metadata(
|
||||||
|
fileEtag: file.etag,
|
||||||
|
imageWidth: imageWidth,
|
||||||
|
imageHeight: imageHeight,
|
||||||
|
exif: exif,
|
||||||
|
);
|
||||||
|
|
||||||
|
await UpdateMetadata(FileRepo(FileCachedDataSource()),
|
||||||
|
AlbumRepo(AlbumCachedDataSource()))(account, file, metadataObj);
|
||||||
|
yield file;
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.severe("[call] Failed while getting metadata", e, stacktrace);
|
||||||
|
yield e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {
|
||||||
|
shouldRun = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final FileRepo fileRepo;
|
||||||
|
|
||||||
|
bool shouldRun = true;
|
||||||
|
|
||||||
|
static final _log =
|
||||||
|
Logger("use_case.update_missing_metadata.UpdateMissingMetadata");
|
||||||
|
}
|
21
lib/web/downloader.dart
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
// ignore: avoid_web_libraries_in_flutter
|
||||||
|
import 'dart:html' as html;
|
||||||
|
// ignore: avoid_web_libraries_in_flutter
|
||||||
|
import 'dart:js' as js;
|
||||||
|
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/platform/downloader.dart' as itf;
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
|
class Downloader extends itf.Downloader {
|
||||||
|
@override
|
||||||
|
downloadFile(Account account, File file) async {
|
||||||
|
final fileRepo = FileRepo(FileCachedDataSource());
|
||||||
|
final fileContent = await fileRepo.getBinary(account, file);
|
||||||
|
js.context.callMethod("webSaveAs", [
|
||||||
|
html.Blob([fileContent]),
|
||||||
|
path.basename(file.path),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
50
lib/web/metadata_loader.dart
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:exifdart/exifdart_memory.dart';
|
||||||
|
import 'package:image_size_getter/image_size_getter.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api.dart';
|
||||||
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/exception.dart';
|
||||||
|
import 'package:nc_photos/image_size_getter_util.dart';
|
||||||
|
import 'package:nc_photos/platform/metadata_loader.dart' as itf;
|
||||||
|
|
||||||
|
class MetadataLoader implements itf.MetadataLoader {
|
||||||
|
// on web we just download the image again, hopefully the browser would
|
||||||
|
// cache it for us (which is sadly not the case :|
|
||||||
|
@override
|
||||||
|
loadCacheFile(Account account, File file) => loadNewFile(account, file);
|
||||||
|
|
||||||
|
@override
|
||||||
|
loadNewFile(Account account, File file) async {
|
||||||
|
final response =
|
||||||
|
await Api(account).files().get(path: api_util.getFileUrlRelative(file));
|
||||||
|
if (!response.isGood) {
|
||||||
|
_log.severe("[loadFile] Failed requesting server: $response");
|
||||||
|
throw ApiException(
|
||||||
|
response: response,
|
||||||
|
message: "Failed communicating with server: ${response.statusCode}");
|
||||||
|
}
|
||||||
|
final resolution =
|
||||||
|
await AsyncImageSizeGetter.getSize(AsyncMemoryInput(response.body));
|
||||||
|
final exif = await readExifFromBytes(response.body);
|
||||||
|
return {
|
||||||
|
if (exif != null) "exif": exif,
|
||||||
|
if (resolution.width > 0 && resolution.height > 0)
|
||||||
|
"resolution": {
|
||||||
|
"width": resolution.width,
|
||||||
|
"height": resolution.height,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
loadFile(Account account, File file) => loadNewFile(account, file);
|
||||||
|
|
||||||
|
@override
|
||||||
|
cancel() {}
|
||||||
|
|
||||||
|
static final _log = Logger("web.metadata_loader.MetadataLoader");
|
||||||
|
}
|
7
lib/web/my_app.dart
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import 'package:idb_shim/idb_browser.dart';
|
||||||
|
import 'package:idb_shim/idb_shim.dart';
|
||||||
|
import 'package:nc_photos/widget/my_app.dart' as itf;
|
||||||
|
|
||||||
|
class MyApp extends itf.MyApp {
|
||||||
|
static IdbFactory getDbFactory() => getIdbFactory();
|
||||||
|
}
|
3
lib/web/platform.dart
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export 'downloader.dart';
|
||||||
|
export 'metadata_loader.dart';
|
||||||
|
export 'my_app.dart';
|
109
lib/widget/account_picker_dialog.dart
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:nc_photos/pref.dart';
|
||||||
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
|
import 'package:nc_photos/widget/home.dart';
|
||||||
|
import 'package:nc_photos/widget/sign_in.dart';
|
||||||
|
|
||||||
|
class AccountPickerDialog extends StatefulWidget {
|
||||||
|
AccountPickerDialog({
|
||||||
|
Key key,
|
||||||
|
@required this.account,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _AccountPickerDialogState();
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountPickerDialogState extends State<AccountPickerDialog> {
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
_accounts = Pref.inst().getAccounts([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
final otherAccountOptions = _accounts
|
||||||
|
.where((a) => a != widget.account)
|
||||||
|
.map((a) => SimpleDialogOption(
|
||||||
|
onPressed: () => _onItemPressed(a),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(a.url),
|
||||||
|
subtitle: Text(a.username),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => _onRemoveItemPressed(a)),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
final addAccountOptions = [
|
||||||
|
SimpleDialogOption(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context)
|
||||||
|
..pop()
|
||||||
|
..pushNamed(SignIn.routeName);
|
||||||
|
},
|
||||||
|
child: Tooltip(
|
||||||
|
message: AppLocalizations.of(context).addServerTooltip,
|
||||||
|
child: const Center(
|
||||||
|
child: const Icon(Icons.add, color: Colors.black54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return SimpleDialog(
|
||||||
|
title: ListTile(
|
||||||
|
title: Text(
|
||||||
|
widget.account.url,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
widget.account.username,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: otherAccountOptions + addAccountOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItemPressed(Account account) {
|
||||||
|
Pref.inst().setCurrentAccountIndex(_accounts.indexOf(account));
|
||||||
|
Navigator.of(context).pushNamedAndRemoveUntil(Home.routeName, (_) => false,
|
||||||
|
arguments: HomeArguments(account));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onRemoveItemPressed(Account account) {
|
||||||
|
_removeAccount(account);
|
||||||
|
setState(() {
|
||||||
|
_accounts = Pref.inst().getAccounts([]);
|
||||||
|
});
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context)
|
||||||
|
.removeServerSuccessNotification(account.url)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeAccount(Account account) {
|
||||||
|
final currentAccounts = Pref.inst().getAccounts([]);
|
||||||
|
final currentAccount =
|
||||||
|
currentAccounts[Pref.inst().getCurrentAccountIndex()];
|
||||||
|
final newAccounts =
|
||||||
|
currentAccounts.where((element) => element != account).toList();
|
||||||
|
final newAccountIndex = newAccounts.indexOf(currentAccount);
|
||||||
|
if (newAccountIndex == -1) {
|
||||||
|
throw StateError("Active account not found in resulting account list");
|
||||||
|
}
|
||||||
|
Pref.inst()
|
||||||
|
..setAccounts(newAccounts)
|
||||||
|
..setCurrentAccountIndex(newAccountIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Account> _accounts;
|
||||||
|
}
|
90
lib/widget/album_grid_item.dart
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:nc_photos/theme.dart';
|
||||||
|
|
||||||
|
class AlbumGridItem extends StatelessWidget {
|
||||||
|
AlbumGridItem({
|
||||||
|
Key key,
|
||||||
|
@required this.cover,
|
||||||
|
@required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.isSelected = false,
|
||||||
|
this.onTap,
|
||||||
|
this.onLongPress,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: cover,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
title ?? "",
|
||||||
|
style: Theme.of(context).textTheme.bodyText1,
|
||||||
|
textAlign: TextAlign.start,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle ?? "",
|
||||||
|
style: Theme.of(context).textTheme.bodyText2.copyWith(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.start,
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelected)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.getSelectionOverlayColor(context),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelected)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(
|
||||||
|
Icons.check_circle_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: AppTheme.getSelectionCheckColor(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Widget cover;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
}
|
153
lib/widget/album_picker_dialog.dart
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/bloc/list_album.dart';
|
||||||
|
import 'package:nc_photos/entity/album.dart';
|
||||||
|
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
|
import 'package:nc_photos/widget/new_album_dialog.dart';
|
||||||
|
|
||||||
|
class AlbumPickerDialog extends StatefulWidget {
|
||||||
|
AlbumPickerDialog({
|
||||||
|
Key key,
|
||||||
|
@required this.account,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _AlbumPickerDialogState();
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlbumPickerDialogState extends State<AlbumPickerDialog> {
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
_initBloc();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return BlocListener<ListAlbumBloc, ListAlbumBlocState>(
|
||||||
|
bloc: _bloc,
|
||||||
|
listener: (context, state) => _onStateChange(context, state),
|
||||||
|
child: BlocBuilder<ListAlbumBloc, ListAlbumBlocState>(
|
||||||
|
bloc: _bloc,
|
||||||
|
builder: (context, state) => _buildContent(context, state),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initBloc() {
|
||||||
|
ListAlbumBloc bloc;
|
||||||
|
final blocId =
|
||||||
|
"${widget.account.scheme}://${widget.account.username}@${widget.account.address}";
|
||||||
|
try {
|
||||||
|
_log.fine("[_initBloc] Resolving bloc for '$blocId'");
|
||||||
|
bloc = KiwiContainer().resolve<ListAlbumBloc>("ListAlbumBloc($blocId)");
|
||||||
|
} catch (_) {
|
||||||
|
// no created instance for this account, make a new one
|
||||||
|
_log.info("[_initBloc] New bloc instance for account: ${widget.account}");
|
||||||
|
bloc = ListAlbumBloc();
|
||||||
|
KiwiContainer().registerInstance<ListAlbumBloc>(bloc,
|
||||||
|
name: "ListAlbumBloc($blocId)");
|
||||||
|
}
|
||||||
|
|
||||||
|
_bloc = bloc;
|
||||||
|
if (_bloc.state is ListAlbumBlocInit) {
|
||||||
|
_log.info("[_initBloc] Initialize bloc");
|
||||||
|
_reqQuery();
|
||||||
|
} else {
|
||||||
|
// process the current state
|
||||||
|
_onStateChange(context, _bloc.state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context, ListAlbumBlocState state) {
|
||||||
|
final newAlbumOptions = [
|
||||||
|
SimpleDialogOption(
|
||||||
|
onPressed: () => _onNewAlbumPressed(context),
|
||||||
|
child: Tooltip(
|
||||||
|
message: AppLocalizations.of(context).createAlbumTooltip,
|
||||||
|
child: const Center(
|
||||||
|
child: const Icon(Icons.add, color: Colors.black54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return Visibility(
|
||||||
|
visible: _isVisible,
|
||||||
|
child: SimpleDialog(
|
||||||
|
children: _items
|
||||||
|
.map((e) => SimpleDialogOption(
|
||||||
|
onPressed: () => _onItemPressed(context, e),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text("${e.name}"),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList() +
|
||||||
|
newAlbumOptions,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStateChange(BuildContext context, ListAlbumBlocState state) {
|
||||||
|
if (state is ListAlbumBlocInit) {
|
||||||
|
_items.clear();
|
||||||
|
} else if (state is ListAlbumBlocSuccess || state is ListAlbumBlocLoading) {
|
||||||
|
_transformItems(state.albums);
|
||||||
|
} else if (state is ListAlbumBlocFailure) {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(exception_util.toUserString(state.exception, context)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
} else if (state is ListAlbumBlocInconsistent) {
|
||||||
|
_reqQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItemPressed(BuildContext context, Album album) {
|
||||||
|
Navigator.of(context).pop(album);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onNewAlbumPressed(BuildContext context) {
|
||||||
|
setState(() {
|
||||||
|
_isVisible = false;
|
||||||
|
});
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => NewAlbumDialog(
|
||||||
|
account: widget.account,
|
||||||
|
),
|
||||||
|
).then((value) {
|
||||||
|
Navigator.of(context).pop(value);
|
||||||
|
}).catchError((e, stacktrace) {
|
||||||
|
_log.severe(
|
||||||
|
"[_onNewAlbumPressed] Failed while showDialog", e, stacktrace);
|
||||||
|
Navigator.of(context).pop(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _transformItems(List<Album> albums) {
|
||||||
|
_items.clear();
|
||||||
|
_items.addAll(albums);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reqQuery() {
|
||||||
|
_bloc.add(ListAlbumBlocQuery(widget.account));
|
||||||
|
}
|
||||||
|
|
||||||
|
ListAlbumBloc _bloc;
|
||||||
|
|
||||||
|
final _items = <Album>[];
|
||||||
|
|
||||||
|
var _isVisible = true;
|
||||||
|
|
||||||
|
static final _log =
|
||||||
|
Logger("widget.album_picker_dialog._AlbumPickerDialogState");
|
||||||
|
}
|
354
lib/widget/album_viewer.dart
Normal file
|
@ -0,0 +1,354 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api.dart';
|
||||||
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||||
|
import 'package:nc_photos/entity/album.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
|
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||||
|
import 'package:nc_photos/iterable_extension.dart';
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:nc_photos/list_extension.dart';
|
||||||
|
import 'package:nc_photos/pref.dart';
|
||||||
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
|
import 'package:nc_photos/theme.dart';
|
||||||
|
import 'package:nc_photos/use_case/update_album.dart';
|
||||||
|
import 'package:nc_photos/widget/image_grid_item.dart';
|
||||||
|
import 'package:nc_photos/widget/popup_menu_zoom.dart';
|
||||||
|
import 'package:nc_photos/widget/viewer.dart';
|
||||||
|
|
||||||
|
class AlbumViewerArguments {
|
||||||
|
AlbumViewerArguments(this.account, this.album);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final Album album;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumViewer extends StatefulWidget {
|
||||||
|
static const routeName = "/album-viewer";
|
||||||
|
|
||||||
|
AlbumViewer({
|
||||||
|
Key key,
|
||||||
|
@required this.account,
|
||||||
|
@required this.album,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
AlbumViewer.fromArgs(AlbumViewerArguments args, {Key key})
|
||||||
|
: this(
|
||||||
|
key: key,
|
||||||
|
account: args.account,
|
||||||
|
album: args.album,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _AlbumViewerState();
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final Album album;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlbumViewerState extends State<AlbumViewer>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
_album = widget.album;
|
||||||
|
_transformItems();
|
||||||
|
_initCover();
|
||||||
|
_thumbZoomLevel = Pref.inst().getAlbumViewerZoomLevel(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return AppTheme(
|
||||||
|
child: Scaffold(
|
||||||
|
body: Builder(builder: (context) => _buildContent(context)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initCover() {
|
||||||
|
try {
|
||||||
|
final coverFile = _backingFiles.first;
|
||||||
|
if (coverFile.hasPreview) {
|
||||||
|
_coverPreviewUrl = api_util.getFilePreviewUrl(widget.account, coverFile,
|
||||||
|
width: 1024, height: 600);
|
||||||
|
} else {
|
||||||
|
_coverPreviewUrl = api_util.getFileUrl(widget.account, coverFile);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
accentColor: AppTheme.getOverscrollIndicatorColor(context),
|
||||||
|
),
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
sliver: SliverStaggeredGrid.extentBuilder(
|
||||||
|
// need to rebuild grid after zoom level changed
|
||||||
|
key: ValueKey(_thumbZoomLevel),
|
||||||
|
maxCrossAxisExtent: _thumbSize.toDouble(),
|
||||||
|
itemCount: _items.length,
|
||||||
|
itemBuilder: _buildItem,
|
||||||
|
staggeredTileBuilder: (index) => const StaggeredTile.count(1, 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context) {
|
||||||
|
if (_isSelectionMode) {
|
||||||
|
return _buildSelectionAppBar(context);
|
||||||
|
} else {
|
||||||
|
return _buildNormalAppBar(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectionAppBar(BuildContext context) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
appBarTheme: AppTheme.getContextualAppBarTheme(context),
|
||||||
|
),
|
||||||
|
child: SliverAppBar(
|
||||||
|
pinned: true,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
title: Text(AppLocalizations.of(context)
|
||||||
|
.selectionAppBarTitle(_selectedItems.length)),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove),
|
||||||
|
tooltip:
|
||||||
|
AppLocalizations.of(context).removeSelectedFromAlbumTooltip,
|
||||||
|
onPressed: () {
|
||||||
|
_onSelectionAppBarRemovePressed();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNormalAppBar(BuildContext context) {
|
||||||
|
Widget cover;
|
||||||
|
try {
|
||||||
|
if (_coverPreviewUrl != null) {
|
||||||
|
cover = Opacity(
|
||||||
|
opacity: 0.25,
|
||||||
|
child: FittedBox(
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: _coverPreviewUrl,
|
||||||
|
httpHeaders: {
|
||||||
|
"Authorization":
|
||||||
|
Api.getAuthorizationHeaderValue(widget.account),
|
||||||
|
},
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return SliverAppBar(
|
||||||
|
floating: true,
|
||||||
|
expandedHeight: 160,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: cover,
|
||||||
|
title: Text(
|
||||||
|
_album.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).textTheme.headline6.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
PopupMenuButton(
|
||||||
|
icon: const Icon(Icons.zoom_in),
|
||||||
|
tooltip: AppLocalizations.of(context).zoomTooltip,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuZoom(
|
||||||
|
initialValue: _thumbZoomLevel,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_thumbZoomLevel = value.round();
|
||||||
|
});
|
||||||
|
Pref.inst().setAlbumViewerZoomLevel(_thumbZoomLevel);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildItem(BuildContext context, int index) {
|
||||||
|
final item = _items[index];
|
||||||
|
if (item is _GridImageItem) {
|
||||||
|
return _buildImageItem(context, item, index);
|
||||||
|
} else {
|
||||||
|
_log.severe("[_buildItem] Unsupported item type: ${item.runtimeType}");
|
||||||
|
throw StateError("Unsupported item type: ${item.runtimeType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageItem(BuildContext context, _GridImageItem item, int index) {
|
||||||
|
return ImageGridItem(
|
||||||
|
account: widget.account,
|
||||||
|
imageUrl: item.previewUrl,
|
||||||
|
isSelected: _selectedItems.contains(item),
|
||||||
|
onTap: () => _onItemTap(item, index),
|
||||||
|
onLongPress:
|
||||||
|
_isSelectionMode ? null : () => _onItemLongPress(item, index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItemTap(_GridItem item, int index) {
|
||||||
|
if (_isSelectionMode) {
|
||||||
|
if (_selectedItems.contains(item)) {
|
||||||
|
// unselect
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.remove(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// select
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.add(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Navigator.pushNamed(context, Viewer.routeName,
|
||||||
|
arguments: ViewerArguments(widget.account, _backingFiles, index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItemLongPress(_GridItem item, int index) {
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.add(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSelectionAppBarRemovePressed() {
|
||||||
|
// currently album's are auto sorted by date, so it's ok to remove items w/o
|
||||||
|
// preserving the order. this will be problematic if we want to allow custom
|
||||||
|
// sorting later
|
||||||
|
final selectedIndexes =
|
||||||
|
_selectedItems.map((e) => _items.indexOf(e)).toList();
|
||||||
|
final selectedFiles = _backingFiles.takeIndex(selectedIndexes).toList();
|
||||||
|
final newItems = _album.items.where((element) {
|
||||||
|
if (element is AlbumFileItem) {
|
||||||
|
return !selectedFiles.contains(element.file);
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
final albumRepo = AlbumRepo(AlbumCachedDataSource());
|
||||||
|
final newAlbum = _album.copyWith(
|
||||||
|
items: newItems,
|
||||||
|
);
|
||||||
|
UpdateAlbum(albumRepo)(widget.account, newAlbum).then((_) {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context)
|
||||||
|
.removeSelectedFromAlbumSuccessNotification(
|
||||||
|
selectedIndexes.length)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
setState(() {
|
||||||
|
_album = newAlbum;
|
||||||
|
_transformItems();
|
||||||
|
});
|
||||||
|
}).catchError((e, stacktrace) {
|
||||||
|
_log.severe("[_onSelectionRemovePressed] Failed while updating album", e,
|
||||||
|
stacktrace);
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(
|
||||||
|
"${AppLocalizations.of(context).removeSelectedFromAlbumFailureNotification}: "
|
||||||
|
"${exception_util.toUserString(e, context)}"),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _transformItems() {
|
||||||
|
_backingFiles = _album.items
|
||||||
|
.whereType<AlbumFileItem>()
|
||||||
|
.map((e) => e.file)
|
||||||
|
.where((element) => file_util.isSupportedFormat(element))
|
||||||
|
.sorted(compareFileDateTimeDescending);
|
||||||
|
|
||||||
|
_items.clear();
|
||||||
|
_items.addAll(_backingFiles.map((e) {
|
||||||
|
var previewUrl;
|
||||||
|
if (e.hasPreview) {
|
||||||
|
previewUrl = api_util.getFilePreviewUrl(widget.account, e,
|
||||||
|
width: _thumbSize, height: _thumbSize);
|
||||||
|
} else {
|
||||||
|
previewUrl = api_util.getFileUrl(widget.account, e);
|
||||||
|
}
|
||||||
|
return _GridItem.image(previewUrl);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
int get _thumbSize {
|
||||||
|
switch (_thumbZoomLevel) {
|
||||||
|
case 1:
|
||||||
|
return 192;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
return 256;
|
||||||
|
|
||||||
|
case 0:
|
||||||
|
default:
|
||||||
|
return 112;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isSelectionMode => _selectedItems.isNotEmpty;
|
||||||
|
|
||||||
|
Album _album;
|
||||||
|
final _items = <_GridItem>[];
|
||||||
|
var _backingFiles = <File>[];
|
||||||
|
|
||||||
|
String _coverPreviewUrl;
|
||||||
|
var _thumbZoomLevel = 0;
|
||||||
|
|
||||||
|
final _selectedItems = <_GridItem>[];
|
||||||
|
|
||||||
|
static final _log = Logger("widget.album_viewer._AlbumViewerState");
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GridItem {
|
||||||
|
const _GridItem();
|
||||||
|
|
||||||
|
factory _GridItem.image(String previewUrl) => _GridImageItem(previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GridImageItem extends _GridItem {
|
||||||
|
_GridImageItem(this.previewUrl);
|
||||||
|
|
||||||
|
final String previewUrl;
|
||||||
|
}
|
298
lib/widget/cached_network_image_mod.dart
Normal file
|
@ -0,0 +1,298 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:octo_image/octo_image.dart';
|
||||||
|
|
||||||
|
/// Builder function to create an image widget. The function is called after
|
||||||
|
/// the ImageProvider completes the image loading.
|
||||||
|
typedef ImageWidgetBuilder = Widget Function(
|
||||||
|
BuildContext context,
|
||||||
|
Widget child,
|
||||||
|
ImageProvider imageProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Image widget to show NetworkImage with caching functionality.
|
||||||
|
class CachedNetworkImage extends StatelessWidget {
|
||||||
|
/// Evict an image from both the disk file based caching system of the
|
||||||
|
/// [BaseCacheManager] as the in memory [ImageCache] of the [ImageProvider].
|
||||||
|
/// [url] is used by both the disk and memory cache. The scale is only used
|
||||||
|
/// to clear the image from the [ImageCache].
|
||||||
|
static Future evictFromCache(
|
||||||
|
String url, {
|
||||||
|
String cacheKey,
|
||||||
|
BaseCacheManager cacheManager,
|
||||||
|
double scale = 1.0,
|
||||||
|
}) async {
|
||||||
|
cacheManager = cacheManager ?? DefaultCacheManager();
|
||||||
|
await cacheManager.removeFile(cacheKey ?? url);
|
||||||
|
return CachedNetworkImageProvider(url, scale: scale).evict();
|
||||||
|
}
|
||||||
|
|
||||||
|
final CachedNetworkImageProvider _image;
|
||||||
|
|
||||||
|
/// Option to use cachemanager with other settings
|
||||||
|
final BaseCacheManager cacheManager;
|
||||||
|
|
||||||
|
/// The target image that is displayed.
|
||||||
|
final String imageUrl;
|
||||||
|
|
||||||
|
/// The target image's cache key.
|
||||||
|
final String cacheKey;
|
||||||
|
|
||||||
|
/// Optional builder to further customize the display of the image.
|
||||||
|
final ImageWidgetBuilder imageBuilder;
|
||||||
|
|
||||||
|
/// Widget displayed while the target [imageUrl] is loading.
|
||||||
|
final PlaceholderWidgetBuilder placeholder;
|
||||||
|
|
||||||
|
/// Widget displayed while the target [imageUrl] is loading.
|
||||||
|
final ProgressIndicatorBuilder progressIndicatorBuilder;
|
||||||
|
|
||||||
|
/// Widget displayed while the target [imageUrl] failed loading.
|
||||||
|
final LoadingErrorWidgetBuilder errorWidget;
|
||||||
|
|
||||||
|
/// The duration of the fade-in animation for the [placeholder].
|
||||||
|
final Duration placeholderFadeInDuration;
|
||||||
|
|
||||||
|
/// The duration of the fade-out animation for the [placeholder].
|
||||||
|
final Duration fadeOutDuration;
|
||||||
|
|
||||||
|
/// The curve of the fade-out animation for the [placeholder].
|
||||||
|
final Curve fadeOutCurve;
|
||||||
|
|
||||||
|
/// The duration of the fade-in animation for the [imageUrl].
|
||||||
|
final Duration fadeInDuration;
|
||||||
|
|
||||||
|
/// The curve of the fade-in animation for the [imageUrl].
|
||||||
|
final Curve fadeInCurve;
|
||||||
|
|
||||||
|
/// If non-null, require the image to have this width.
|
||||||
|
///
|
||||||
|
/// If null, the image will pick a size that best preserves its intrinsic
|
||||||
|
/// aspect ratio. This may result in a sudden change if the size of the
|
||||||
|
/// placeholder widget does not match that of the target image. The size is
|
||||||
|
/// also affected by the scale factor.
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
/// If non-null, require the image to have this height.
|
||||||
|
///
|
||||||
|
/// If null, the image will pick a size that best preserves its intrinsic
|
||||||
|
/// aspect ratio. This may result in a sudden change if the size of the
|
||||||
|
/// placeholder widget does not match that of the target image. The size is
|
||||||
|
/// also affected by the scale factor.
|
||||||
|
final double height;
|
||||||
|
|
||||||
|
/// How to inscribe the image into the space allocated during layout.
|
||||||
|
///
|
||||||
|
/// The default varies based on the other fields. See the discussion at
|
||||||
|
/// [paintImage].
|
||||||
|
final BoxFit fit;
|
||||||
|
|
||||||
|
/// How to align the image within its bounds.
|
||||||
|
///
|
||||||
|
/// The alignment aligns the given position in the image to the given position
|
||||||
|
/// in the layout bounds. For example, a [Alignment] alignment of (-1.0,
|
||||||
|
/// -1.0) aligns the image to the top-left corner of its layout bounds, while a
|
||||||
|
/// [Alignment] alignment of (1.0, 1.0) aligns the bottom right of the
|
||||||
|
/// image with the bottom right corner of its layout bounds. Similarly, an
|
||||||
|
/// alignment of (0.0, 1.0) aligns the bottom middle of the image with the
|
||||||
|
/// middle of the bottom edge of its layout bounds.
|
||||||
|
///
|
||||||
|
/// If the [alignment] is [TextDirection]-dependent (i.e. if it is a
|
||||||
|
/// [AlignmentDirectional]), then an ambient [Directionality] widget
|
||||||
|
/// must be in scope.
|
||||||
|
///
|
||||||
|
/// Defaults to [Alignment.center].
|
||||||
|
///
|
||||||
|
/// See also:
|
||||||
|
///
|
||||||
|
/// * [Alignment], a class with convenient constants typically used to
|
||||||
|
/// specify an [AlignmentGeometry].
|
||||||
|
/// * [AlignmentDirectional], like [Alignment] for specifying alignments
|
||||||
|
/// relative to text direction.
|
||||||
|
final AlignmentGeometry alignment;
|
||||||
|
|
||||||
|
/// How to paint any portions of the layout bounds not covered by the image.
|
||||||
|
final ImageRepeat repeat;
|
||||||
|
|
||||||
|
/// Whether to paint the image in the direction of the [TextDirection].
|
||||||
|
///
|
||||||
|
/// If this is true, then in [TextDirection.ltr] contexts, the image will be
|
||||||
|
/// drawn with its origin in the top left (the "normal" painting direction for
|
||||||
|
/// children); and in [TextDirection.rtl] contexts, the image will be drawn with
|
||||||
|
/// a scaling factor of -1 in the horizontal direction so that the origin is
|
||||||
|
/// in the top right.
|
||||||
|
///
|
||||||
|
/// This is occasionally used with children in right-to-left environments, for
|
||||||
|
/// children that were designed for left-to-right locales. Be careful, when
|
||||||
|
/// using this, to not flip children with integral shadows, text, or other
|
||||||
|
/// effects that will look incorrect when flipped.
|
||||||
|
///
|
||||||
|
/// If this is true, there must be an ambient [Directionality] widget in
|
||||||
|
/// scope.
|
||||||
|
final bool matchTextDirection;
|
||||||
|
|
||||||
|
/// Optional headers for the http request of the image url
|
||||||
|
final Map<String, String> httpHeaders;
|
||||||
|
|
||||||
|
/// When set to true it will animate from the old image to the new image
|
||||||
|
/// if the url changes.
|
||||||
|
final bool useOldImageOnUrlChange;
|
||||||
|
|
||||||
|
/// If non-null, this color is blended with each image pixel using [colorBlendMode].
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
/// Used to combine [color] with this image.
|
||||||
|
///
|
||||||
|
/// The default is [BlendMode.srcIn]. In terms of the blend mode, [color] is
|
||||||
|
/// the source and this image is the destination.
|
||||||
|
///
|
||||||
|
/// See also:
|
||||||
|
///
|
||||||
|
/// * [BlendMode], which includes an illustration of the effect of each blend mode.
|
||||||
|
final BlendMode colorBlendMode;
|
||||||
|
|
||||||
|
/// Target the interpolation quality for image scaling.
|
||||||
|
///
|
||||||
|
/// If not given a value, defaults to FilterQuality.low.
|
||||||
|
final FilterQuality filterQuality;
|
||||||
|
|
||||||
|
/// Will resize the image in memory to have a certain width using [ResizeImage]
|
||||||
|
final int memCacheWidth;
|
||||||
|
|
||||||
|
/// Will resize the image in memory to have a certain height using [ResizeImage]
|
||||||
|
final int memCacheHeight;
|
||||||
|
|
||||||
|
/// Will resize the image and store the resized image in the disk cache.
|
||||||
|
final int maxWidthDiskCache;
|
||||||
|
|
||||||
|
/// Will resize the image and store the resized image in the disk cache.
|
||||||
|
final int maxHeightDiskCache;
|
||||||
|
|
||||||
|
/// CachedNetworkImage shows a network image using a caching mechanism. It also
|
||||||
|
/// provides support for a placeholder, showing an error and fading into the
|
||||||
|
/// loaded image. Next to that it supports most features of a default Image
|
||||||
|
/// widget.
|
||||||
|
CachedNetworkImage({
|
||||||
|
Key key,
|
||||||
|
@required this.imageUrl,
|
||||||
|
this.httpHeaders,
|
||||||
|
this.imageBuilder,
|
||||||
|
this.placeholder,
|
||||||
|
this.progressIndicatorBuilder,
|
||||||
|
this.errorWidget,
|
||||||
|
this.fadeOutDuration = const Duration(milliseconds: 1000),
|
||||||
|
this.fadeOutCurve = Curves.easeOut,
|
||||||
|
this.fadeInDuration = const Duration(milliseconds: 500),
|
||||||
|
this.fadeInCurve = Curves.easeIn,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.fit,
|
||||||
|
this.alignment = Alignment.center,
|
||||||
|
this.repeat = ImageRepeat.noRepeat,
|
||||||
|
this.matchTextDirection = false,
|
||||||
|
this.cacheManager,
|
||||||
|
this.useOldImageOnUrlChange = false,
|
||||||
|
this.color,
|
||||||
|
this.filterQuality = FilterQuality.low,
|
||||||
|
this.colorBlendMode,
|
||||||
|
this.placeholderFadeInDuration,
|
||||||
|
this.memCacheWidth,
|
||||||
|
this.memCacheHeight,
|
||||||
|
this.cacheKey,
|
||||||
|
this.maxWidthDiskCache,
|
||||||
|
this.maxHeightDiskCache,
|
||||||
|
ImageRenderMethodForWeb imageRenderMethodForWeb,
|
||||||
|
}) : assert(imageUrl != null),
|
||||||
|
assert(fadeOutDuration != null),
|
||||||
|
assert(fadeOutCurve != null),
|
||||||
|
assert(fadeInDuration != null),
|
||||||
|
assert(fadeInCurve != null),
|
||||||
|
assert(alignment != null),
|
||||||
|
assert(filterQuality != null),
|
||||||
|
assert(repeat != null),
|
||||||
|
assert(matchTextDirection != null),
|
||||||
|
_image = CachedNetworkImageProvider(
|
||||||
|
imageUrl,
|
||||||
|
headers: httpHeaders,
|
||||||
|
cacheManager: cacheManager,
|
||||||
|
cacheKey: cacheKey,
|
||||||
|
imageRenderMethodForWeb: imageRenderMethodForWeb,
|
||||||
|
maxWidth: maxWidthDiskCache,
|
||||||
|
maxHeight: maxHeightDiskCache,
|
||||||
|
),
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
var octoPlaceholderBuilder =
|
||||||
|
placeholder != null ? _octoPlaceholderBuilder : null;
|
||||||
|
var octoProgressIndicatorBuilder =
|
||||||
|
progressIndicatorBuilder != null ? _octoProgressIndicatorBuilder : null;
|
||||||
|
|
||||||
|
///If there is no placeholer OctoImage does not fade, so always set an
|
||||||
|
///(empty) placeholder as this always used to be the behaviour of
|
||||||
|
///CachedNetworkImage.
|
||||||
|
if (octoPlaceholderBuilder == null &&
|
||||||
|
octoProgressIndicatorBuilder == null) {
|
||||||
|
octoPlaceholderBuilder = (context) => Container();
|
||||||
|
}
|
||||||
|
|
||||||
|
return OctoImage(
|
||||||
|
image: _image,
|
||||||
|
imageBuilder: imageBuilder != null ? _octoImageBuilder : null,
|
||||||
|
placeholderBuilder: octoPlaceholderBuilder,
|
||||||
|
progressIndicatorBuilder: octoProgressIndicatorBuilder,
|
||||||
|
errorBuilder: errorWidget != null ? _octoErrorBuilder : null,
|
||||||
|
fadeOutDuration: fadeOutDuration,
|
||||||
|
fadeOutCurve: fadeOutCurve,
|
||||||
|
fadeInDuration: fadeInDuration,
|
||||||
|
fadeInCurve: fadeInCurve,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
fit: fit,
|
||||||
|
alignment: alignment,
|
||||||
|
repeat: repeat,
|
||||||
|
matchTextDirection: matchTextDirection,
|
||||||
|
color: color,
|
||||||
|
filterQuality: filterQuality,
|
||||||
|
colorBlendMode: colorBlendMode,
|
||||||
|
placeholderFadeInDuration: placeholderFadeInDuration,
|
||||||
|
gaplessPlayback: useOldImageOnUrlChange,
|
||||||
|
memCacheWidth: memCacheWidth,
|
||||||
|
memCacheHeight: memCacheHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _octoImageBuilder(BuildContext context, Widget child) {
|
||||||
|
return imageBuilder(context, child, _image);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _octoPlaceholderBuilder(BuildContext context) {
|
||||||
|
return placeholder(context, imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _octoProgressIndicatorBuilder(
|
||||||
|
BuildContext context,
|
||||||
|
ImageChunkEvent progress,
|
||||||
|
) {
|
||||||
|
int totalSize;
|
||||||
|
var downloaded = 0;
|
||||||
|
if (progress != null) {
|
||||||
|
totalSize = progress.expectedTotalBytes;
|
||||||
|
downloaded = progress.cumulativeBytesLoaded;
|
||||||
|
}
|
||||||
|
return progressIndicatorBuilder(
|
||||||
|
context, imageUrl, DownloadProgress(imageUrl, totalSize, downloaded));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _octoErrorBuilder(
|
||||||
|
BuildContext context,
|
||||||
|
Object error,
|
||||||
|
StackTrace stackTrace,
|
||||||
|
) {
|
||||||
|
return errorWidget(context, imageUrl, error);
|
||||||
|
}
|
||||||
|
}
|
113
lib/widget/connect.dart
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/bloc/app_password_exchange.dart';
|
||||||
|
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
|
import 'package:nc_photos/theme.dart';
|
||||||
|
|
||||||
|
class ConnectArguments {
|
||||||
|
ConnectArguments(this.account);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Connect extends StatefulWidget {
|
||||||
|
static const routeName = "/connect";
|
||||||
|
|
||||||
|
Connect({
|
||||||
|
Key key,
|
||||||
|
@required this.account,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
Connect.fromArgs(ConnectArguments args, {Key key})
|
||||||
|
: this(
|
||||||
|
key: key,
|
||||||
|
account: args.account,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _ConnectState();
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConnectState extends State<Connect> {
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
_initBloc();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return AppTheme(
|
||||||
|
child: Scaffold(
|
||||||
|
body:
|
||||||
|
BlocListener<AppPasswordExchangeBloc, AppPasswordExchangeBlocState>(
|
||||||
|
bloc: _bloc,
|
||||||
|
listener: (context, state) => _onStateChange(context, state),
|
||||||
|
child: Builder(builder: (context) => _buildContent(context)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initBloc() {
|
||||||
|
_log.info("[_initBloc] Initialize bloc");
|
||||||
|
_connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.cloud,
|
||||||
|
size: 128,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context)
|
||||||
|
.connectingToServer(widget.account.url),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.headline6,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStateChange(
|
||||||
|
BuildContext context, AppPasswordExchangeBlocState state) {
|
||||||
|
if (state is AppPasswordExchangeBlocSuccess) {
|
||||||
|
final newAccount = widget.account.copyWith(password: state.password);
|
||||||
|
_log.info("[_onStateChange] Account is good: $newAccount");
|
||||||
|
Navigator.of(context).pop(newAccount);
|
||||||
|
} else if (state is AppPasswordExchangeBlocFailure) {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(exception_util.toUserString(state.exception, context)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _connect() {
|
||||||
|
_bloc.add(AppPasswordExchangeBlocConnect(widget.account));
|
||||||
|
}
|
||||||
|
|
||||||
|
final _bloc = AppPasswordExchangeBloc();
|
||||||
|
|
||||||
|
static final _log = Logger("widget.connect._ConnectState");
|
||||||
|
}
|
121
lib/widget/home.dart
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:nc_photos/metadata_task_manager.dart';
|
||||||
|
import 'package:nc_photos/pref.dart';
|
||||||
|
import 'package:nc_photos/theme.dart';
|
||||||
|
import 'package:nc_photos/widget/home_albums.dart';
|
||||||
|
import 'package:nc_photos/widget/home_photos.dart';
|
||||||
|
|
||||||
|
class HomeArguments {
|
||||||
|
HomeArguments(this.account);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Home extends StatefulWidget {
|
||||||
|
static const routeName = "/home";
|
||||||
|
|
||||||
|
Home({
|
||||||
|
Key key,
|
||||||
|
@required this.account,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
Home.fromArgs(HomeArguments args, {Key key})
|
||||||
|
: this(
|
||||||
|
account: args.account,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _HomeState();
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeState extends State<Home> {
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
if (Pref.inst().isEnableExif()) {
|
||||||
|
KiwiContainer()
|
||||||
|
.resolve<MetadataTaskManager>()
|
||||||
|
.addTask(MetadataTask(widget.account));
|
||||||
|
}
|
||||||
|
_pageController = PageController(initialPage: 0, keepPage: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return AppTheme(
|
||||||
|
child: Scaffold(
|
||||||
|
bottomNavigationBar: _buildBottomNavigationBar(context),
|
||||||
|
body: Builder(builder: (context) => _buildContent(context)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomNavigationBar(BuildContext context) {
|
||||||
|
return BottomNavigationBar(
|
||||||
|
items: <BottomNavigationBarItem>[
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.photo_outlined),
|
||||||
|
label: AppLocalizations.of(context).photosTabLabel,
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.photo_album_outlined),
|
||||||
|
label: AppLocalizations.of(context).albumsTabLabel,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
currentIndex: _nextPage,
|
||||||
|
onTap: _onTapNavItem,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context) {
|
||||||
|
return PageView.builder(
|
||||||
|
controller: _pageController,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: 2,
|
||||||
|
itemBuilder: _buildPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPage(BuildContext context, int index) {
|
||||||
|
switch (index) {
|
||||||
|
case 0:
|
||||||
|
return _buildPhotosPage(context);
|
||||||
|
|
||||||
|
case 1:
|
||||||
|
return _buildAlbumsPage(context);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw ArgumentError("Invalid page index: $index");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPhotosPage(BuildContext context) {
|
||||||
|
return HomePhotos(
|
||||||
|
account: widget.account,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumsPage(BuildContext context) {
|
||||||
|
return HomeAlbums(
|
||||||
|
account: widget.account,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTapNavItem(int index) {
|
||||||
|
_pageController.animateToPage(index,
|
||||||
|
duration: k.animationDurationNormal, curve: Curves.easeInOut);
|
||||||
|
setState(() {
|
||||||
|
_nextPage = index;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PageController _pageController;
|
||||||
|
int _nextPage = 0;
|
||||||
|
}
|
428
lib/widget/home_albums.dart
Normal file
|
@ -0,0 +1,428 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api.dart';
|
||||||
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||||
|
import 'package:nc_photos/bloc/list_album.dart';
|
||||||
|
import 'package:nc_photos/entity/album.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
|
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||||
|
import 'package:nc_photos/iterable_extension.dart';
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
|
import 'package:nc_photos/theme.dart';
|
||||||
|
import 'package:nc_photos/use_case/remove.dart';
|
||||||
|
import 'package:nc_photos/widget/album_grid_item.dart';
|
||||||
|
import 'package:nc_photos/widget/album_viewer.dart';
|
||||||
|
import 'package:nc_photos/widget/home_app_bar.dart';
|
||||||
|
import 'package:nc_photos/widget/new_album_dialog.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
class HomeAlbums extends StatefulWidget {
|
||||||
|
HomeAlbums({
|
||||||
|
Key key,
|
||||||
|
@required this.account,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _HomeAlbumsState();
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeAlbumsState extends State<HomeAlbums> {
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
_initBloc();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return BlocListener<ListAlbumBloc, ListAlbumBlocState>(
|
||||||
|
bloc: _bloc,
|
||||||
|
listener: (context, state) => _onStateChange(context, state),
|
||||||
|
child: BlocBuilder<ListAlbumBloc, ListAlbumBlocState>(
|
||||||
|
bloc: _bloc,
|
||||||
|
builder: (context, state) => _buildContent(context, state),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initBloc() {
|
||||||
|
ListAlbumBloc bloc;
|
||||||
|
final blocId =
|
||||||
|
"${widget.account.scheme}://${widget.account.username}@${widget.account.address}";
|
||||||
|
try {
|
||||||
|
_log.fine("[_initBloc] Resolving bloc for '$blocId'");
|
||||||
|
bloc = KiwiContainer().resolve<ListAlbumBloc>("ListAlbumBloc($blocId)");
|
||||||
|
} catch (e) {
|
||||||
|
// no created instance for this account, make a new one
|
||||||
|
_log.info("[_initBloc] New bloc instance for account: ${widget.account}");
|
||||||
|
bloc = ListAlbumBloc();
|
||||||
|
KiwiContainer().registerInstance<ListAlbumBloc>(bloc,
|
||||||
|
name: "ListAlbumBloc($blocId)");
|
||||||
|
}
|
||||||
|
|
||||||
|
_bloc = bloc;
|
||||||
|
if (_bloc.state is ListAlbumBlocInit) {
|
||||||
|
_log.info("[_initBloc] Initialize bloc");
|
||||||
|
_reqQuery();
|
||||||
|
} else {
|
||||||
|
// process the current state
|
||||||
|
_onStateChange(context, _bloc.state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context, ListAlbumBlocState state) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
accentColor: AppTheme.getOverscrollIndicatorColor(context),
|
||||||
|
),
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
sliver: SliverStaggeredGrid.extentBuilder(
|
||||||
|
maxCrossAxisExtent: 256,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
itemCount: _items.length + (_isSelectionMode ? 0 : 1),
|
||||||
|
itemBuilder: _buildItem,
|
||||||
|
staggeredTileBuilder: (index) {
|
||||||
|
return const StaggeredTile.count(1, 1);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state is ListAlbumBlocLoading)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: const LinearProgressIndicator(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context) {
|
||||||
|
if (_isSelectionMode) {
|
||||||
|
return _buildSelectionAppBar(context);
|
||||||
|
} else {
|
||||||
|
return _buildNormalAppBar(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectionAppBar(BuildContext conetxt) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
appBarTheme: AppTheme.getContextualAppBarTheme(context),
|
||||||
|
),
|
||||||
|
child: SliverAppBar(
|
||||||
|
pinned: true,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
title: Text(AppLocalizations.of(context)
|
||||||
|
.selectionAppBarTitle(_selectedItems.length)),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
tooltip: AppLocalizations.of(context).deleteSelectedTooltip,
|
||||||
|
onPressed: () {
|
||||||
|
_onSelectionAppBarDeletePressed();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNormalAppBar(BuildContext context) {
|
||||||
|
return HomeSliverAppBar(
|
||||||
|
account: widget.account,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildItem(BuildContext context, int index) {
|
||||||
|
if (index < _items.length) {
|
||||||
|
return _buildAlbumItem(context, index);
|
||||||
|
} else {
|
||||||
|
return _buildNewAlbumItem(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumItem(BuildContext context, int index) {
|
||||||
|
final item = _items[index];
|
||||||
|
return AlbumGridItem(
|
||||||
|
cover: _buildAlbumCover(context, item.album),
|
||||||
|
title: item.album.name,
|
||||||
|
subtitle: AppLocalizations.of(context).albumSize(item.album.items.length),
|
||||||
|
isSelected: _selectedItems.contains(item),
|
||||||
|
onTap: () => _onItemTap(item),
|
||||||
|
onLongPress: _isSelectionMode ? null : () => _onItemLongPress(item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumCover(BuildContext context, Album album) {
|
||||||
|
Widget cover;
|
||||||
|
try {
|
||||||
|
// use the latest file as cover
|
||||||
|
final latestFile = album.items
|
||||||
|
.whereType<AlbumFileItem>()
|
||||||
|
.map((e) => e.file)
|
||||||
|
.where((element) => file_util.isSupportedFormat(element))
|
||||||
|
.sorted(compareFileDateTimeDescending)
|
||||||
|
.first;
|
||||||
|
|
||||||
|
String previewUrl;
|
||||||
|
if (latestFile.hasPreview) {
|
||||||
|
previewUrl = api_util.getFilePreviewUrl(widget.account, latestFile,
|
||||||
|
width: 512, height: 512);
|
||||||
|
} else {
|
||||||
|
previewUrl = api_util.getFileUrl(widget.account, latestFile);
|
||||||
|
}
|
||||||
|
cover = FittedBox(
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: previewUrl,
|
||||||
|
httpHeaders: {
|
||||||
|
"Authorization": Api.getAuthorizationHeaderValue(widget.account),
|
||||||
|
},
|
||||||
|
fadeInDuration: const Duration(),
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
cover = const Icon(
|
||||||
|
Icons.panorama,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 96,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
color: Theme.of(context).brightness == Brightness.light
|
||||||
|
? Colors.black.withAlpha(40)
|
||||||
|
: Colors.white.withAlpha(40),
|
||||||
|
constraints: const BoxConstraints.expand(),
|
||||||
|
child: cover,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNewAlbumItem(BuildContext context) {
|
||||||
|
return AlbumGridItem(
|
||||||
|
cover: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
color: Theme.of(context).brightness == Brightness.light
|
||||||
|
? Colors.black.withAlpha(40)
|
||||||
|
: Colors.white.withAlpha(40),
|
||||||
|
constraints: const BoxConstraints.expand(),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.add,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 96,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: AppLocalizations.of(context).createAlbumTooltip,
|
||||||
|
onTap: () => _onNewAlbumItemTap(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStateChange(BuildContext context, ListAlbumBlocState state) {
|
||||||
|
if (state is ListAlbumBlocInit) {
|
||||||
|
_items.clear();
|
||||||
|
} else if (state is ListAlbumBlocSuccess || state is ListAlbumBlocLoading) {
|
||||||
|
_transformItems(state.albums);
|
||||||
|
} else if (state is ListAlbumBlocFailure) {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(exception_util.toUserString(state.exception, context)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
} else if (state is ListAlbumBlocInconsistent) {
|
||||||
|
_reqQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItemTap(_GridItem item) {
|
||||||
|
if (_isSelectionMode) {
|
||||||
|
if (!_items.contains(item)) {
|
||||||
|
_log.warning("[_onItemTap] Item not found in backing list, ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_selectedItems.contains(item)) {
|
||||||
|
// unselect
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.remove(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// select
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.add(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Navigator.of(context).pushNamed(AlbumViewer.routeName,
|
||||||
|
arguments: AlbumViewerArguments(widget.account, item.album));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItemLongPress(_GridItem item) {
|
||||||
|
if (!_items.contains(item)) {
|
||||||
|
_log.warning(
|
||||||
|
"[_onItemLongPress] Item not found in backing list, ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.add(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onNewAlbumItemTap(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => NewAlbumDialog(
|
||||||
|
account: widget.account,
|
||||||
|
),
|
||||||
|
).catchError((e, stacktrace) {
|
||||||
|
_log.severe(
|
||||||
|
"[_onNewAlbumItemTap] Failed while showDialog", e, stacktrace);
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content:
|
||||||
|
Text(AppLocalizations.of(context).createAlbumFailureNotification),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSelectionAppBarDeletePressed() async {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context)
|
||||||
|
.deleteSelectedProcessingNotification(_selectedItems.length)),
|
||||||
|
duration: k.snackBarDurationShort,
|
||||||
|
));
|
||||||
|
final selectedFiles = _selectedItems.map((e) => e.album.albumFile).toList();
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.clear();
|
||||||
|
});
|
||||||
|
final fileRepo = FileRepo(FileCachedDataSource());
|
||||||
|
final albumRepo = AlbumRepo(AlbumCachedDataSource());
|
||||||
|
final failures = <File>[];
|
||||||
|
for (final f in selectedFiles) {
|
||||||
|
try {
|
||||||
|
await Remove(fileRepo, albumRepo)(widget.account, f);
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.severe(
|
||||||
|
"[_onSelectionAppBarDeletePressed] Failed while removing file: ${f.path}",
|
||||||
|
e,
|
||||||
|
stacktrace);
|
||||||
|
failures.add(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.isEmpty) {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(
|
||||||
|
AppLocalizations.of(context).deleteSelectedSuccessNotification),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context)
|
||||||
|
.deleteSelectedFailureNotification(failures.length)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transform an Album list to grid items
|
||||||
|
void _transformItems(List<Album> albums) {
|
||||||
|
final sortedAlbums = albums.map((e) {
|
||||||
|
// find the latest file in this album
|
||||||
|
try {
|
||||||
|
return Tuple2(
|
||||||
|
e.items
|
||||||
|
.whereType<AlbumFileItem>()
|
||||||
|
.map((e) => e.file)
|
||||||
|
.where((element) => file_util.isSupportedFormat(element))
|
||||||
|
.sorted(compareFileDateTimeDescending)
|
||||||
|
.first
|
||||||
|
.lastModified,
|
||||||
|
e);
|
||||||
|
} catch (_) {
|
||||||
|
return Tuple2(e.lastUpdated, e);
|
||||||
|
}
|
||||||
|
}).sorted((a, b) {
|
||||||
|
// then sort in descending order
|
||||||
|
final tmp = b.item1.compareTo(a.item1);
|
||||||
|
if (tmp != 0) {
|
||||||
|
return tmp;
|
||||||
|
} else {
|
||||||
|
return a.item2.name.compareTo(b.item2.name);
|
||||||
|
}
|
||||||
|
}).map((e) => e.item2);
|
||||||
|
_items.clear();
|
||||||
|
_items.addAll(sortedAlbums.map((e) => _GridItem(e)));
|
||||||
|
|
||||||
|
_transformSelectedItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map selected items to the new item list
|
||||||
|
void _transformSelectedItems() {
|
||||||
|
final newSelectedItems = _selectedItems
|
||||||
|
.map((from) {
|
||||||
|
try {
|
||||||
|
return _items.whereType<_GridItem>().firstWhere(
|
||||||
|
(to) => from.album.albumFile.path == to.album.albumFile.path);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.where((element) => element != null)
|
||||||
|
.toList();
|
||||||
|
_selectedItems
|
||||||
|
..clear()
|
||||||
|
..addAll(newSelectedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reqQuery() {
|
||||||
|
_bloc.add(ListAlbumBlocQuery(widget.account));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isSelectionMode => _selectedItems.isNotEmpty;
|
||||||
|
|
||||||
|
ListAlbumBloc _bloc;
|
||||||
|
|
||||||
|
final _items = <_GridItem>[];
|
||||||
|
final _selectedItems = <_GridItem>[];
|
||||||
|
|
||||||
|
static final _log = Logger("widget.home_albums._HomeAlbumsState");
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GridItem {
|
||||||
|
_GridItem(this.album);
|
||||||
|
|
||||||
|
Album album;
|
||||||
|
}
|
99
lib/widget/home_app_bar.dart
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/widget/account_picker_dialog.dart';
|
||||||
|
import 'package:nc_photos/widget/settings.dart';
|
||||||
|
|
||||||
|
/// AppBar for home screens
|
||||||
|
class HomeSliverAppBar extends StatelessWidget {
|
||||||
|
HomeSliverAppBar({
|
||||||
|
Key key,
|
||||||
|
@required this.account,
|
||||||
|
this.actions,
|
||||||
|
this.menuActions,
|
||||||
|
this.onSelectedMenuActions,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return SliverAppBar(
|
||||||
|
title: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AccountPickerDialog(
|
||||||
|
account: account,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.cloud,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
account.url,
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
account.username,
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floating: true,
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
actions: (actions ?? []) +
|
||||||
|
[
|
||||||
|
PopupMenuButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
|
||||||
|
itemBuilder: (context) =>
|
||||||
|
(menuActions ?? []) +
|
||||||
|
[
|
||||||
|
PopupMenuItem(
|
||||||
|
value: _menuValueAbout,
|
||||||
|
child:
|
||||||
|
Text(AppLocalizations.of(context).settingsMenuLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSelected: (option) {
|
||||||
|
if (option >= 0) {
|
||||||
|
onSelectedMenuActions?.call(option);
|
||||||
|
} else {
|
||||||
|
if (option == _menuValueAbout) {
|
||||||
|
Navigator.of(context).pushNamed(Settings.routeName,
|
||||||
|
arguments: SettingsArguments(account));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
|
||||||
|
/// Screen specific action buttons
|
||||||
|
final List<Widget> actions;
|
||||||
|
|
||||||
|
/// Screen specific actions under the overflow menu. The value of each item
|
||||||
|
/// much >= 0
|
||||||
|
final List<PopupMenuEntry<int>> menuActions;
|
||||||
|
final void Function(int) onSelectedMenuActions;
|
||||||
|
|
||||||
|
static const _menuValueAbout = -1;
|
||||||
|
}
|
527
lib/widget/home_photos.dart
Normal file
|
@ -0,0 +1,527 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||||
|
import 'package:nc_photos/bloc/scan_dir.dart';
|
||||||
|
import 'package:nc_photos/entity/album.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
|
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||||
|
import 'package:nc_photos/iterable_extension.dart';
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:nc_photos/pref.dart';
|
||||||
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
|
import 'package:nc_photos/theme.dart';
|
||||||
|
import 'package:nc_photos/use_case/remove.dart';
|
||||||
|
import 'package:nc_photos/use_case/update_album.dart';
|
||||||
|
import 'package:nc_photos/widget/album_picker_dialog.dart';
|
||||||
|
import 'package:nc_photos/widget/home_app_bar.dart';
|
||||||
|
import 'package:nc_photos/widget/image_grid_item.dart';
|
||||||
|
import 'package:nc_photos/widget/popup_menu_zoom.dart';
|
||||||
|
import 'package:nc_photos/widget/viewer.dart';
|
||||||
|
|
||||||
|
class HomePhotos extends StatefulWidget {
|
||||||
|
HomePhotos({
|
||||||
|
Key key,
|
||||||
|
@required this.account,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _HomePhotosState();
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomePhotosState extends State<HomePhotos> {
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
_initBloc();
|
||||||
|
_thumbZoomLevel = Pref.inst().getHomePhotosZoomLevel(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return BlocListener<ScanDirBloc, ScanDirBlocState>(
|
||||||
|
bloc: _bloc,
|
||||||
|
listener: (context, state) => _onStateChange(context, state),
|
||||||
|
child: BlocBuilder<ScanDirBloc, ScanDirBlocState>(
|
||||||
|
bloc: _bloc,
|
||||||
|
builder: (context, state) => _buildContent(context, state),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initBloc() {
|
||||||
|
ScanDirBloc bloc;
|
||||||
|
final blocId =
|
||||||
|
"${widget.account.scheme}://${widget.account.username}@${widget.account.address}?${widget.account.roots.join('&')}";
|
||||||
|
try {
|
||||||
|
_log.fine("[_initBloc] Resolving bloc for '$blocId'");
|
||||||
|
bloc = KiwiContainer().resolve<ScanDirBloc>("ScanDirBloc($blocId)");
|
||||||
|
} catch (e) {
|
||||||
|
// no created instance for this account, make a new one
|
||||||
|
_log.info("[_initBloc] New bloc instance for account: ${widget.account}");
|
||||||
|
bloc = ScanDirBloc();
|
||||||
|
KiwiContainer()
|
||||||
|
.registerInstance<ScanDirBloc>(bloc, name: "ScanDirBloc($blocId)");
|
||||||
|
}
|
||||||
|
|
||||||
|
_bloc = bloc;
|
||||||
|
if (_bloc.state is ScanDirBlocInit) {
|
||||||
|
_log.info("[_initBloc] Initialize bloc");
|
||||||
|
_reqQuery(widget.account.roots);
|
||||||
|
} else {
|
||||||
|
// process the current state
|
||||||
|
_onStateChange(context, _bloc.state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context, ScanDirBlocState state) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
accentColor: AppTheme.getOverscrollIndicatorColor(context),
|
||||||
|
),
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
sliver: SliverStaggeredGrid.extentBuilder(
|
||||||
|
// need to rebuild grid after zoom level changed
|
||||||
|
key: ValueKey(_thumbZoomLevel),
|
||||||
|
maxCrossAxisExtent: _thumbSize.toDouble(),
|
||||||
|
itemCount: _items.length,
|
||||||
|
itemBuilder: _buildItem,
|
||||||
|
staggeredTileBuilder: (index) {
|
||||||
|
if (_items[index] is _GridSubtitleItem) {
|
||||||
|
return const StaggeredTile.extent(99, 32);
|
||||||
|
} else {
|
||||||
|
return const StaggeredTile.count(1, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state is ScanDirBlocLoading)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: const LinearProgressIndicator(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context) {
|
||||||
|
if (_isSelectionMode) {
|
||||||
|
return _buildSelectionAppBar(context);
|
||||||
|
} else {
|
||||||
|
return _buildNormalAppBar(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectionAppBar(BuildContext conetxt) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
appBarTheme: AppTheme.getContextualAppBarTheme(context),
|
||||||
|
),
|
||||||
|
child: SliverAppBar(
|
||||||
|
pinned: true,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
title: Text(AppLocalizations.of(context)
|
||||||
|
.selectionAppBarTitle(_selectedItems.length)),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.playlist_add),
|
||||||
|
tooltip: AppLocalizations.of(context).addSelectedToAlbumTooltip,
|
||||||
|
onPressed: () {
|
||||||
|
_onSelectionAppBarAddToAlbumPressed(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
tooltip: AppLocalizations.of(context).deleteSelectedTooltip,
|
||||||
|
onPressed: () {
|
||||||
|
_onSelectionAppBarDeletePressed(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNormalAppBar(BuildContext context) {
|
||||||
|
return HomeSliverAppBar(
|
||||||
|
account: widget.account,
|
||||||
|
actions: [
|
||||||
|
PopupMenuButton(
|
||||||
|
icon: const Icon(Icons.zoom_in),
|
||||||
|
tooltip: AppLocalizations.of(context).zoomTooltip,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuZoom(
|
||||||
|
initialValue: _thumbZoomLevel,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_thumbZoomLevel = value.round();
|
||||||
|
});
|
||||||
|
Pref.inst().setHomePhotosZoomLevel(_thumbZoomLevel);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildItem(BuildContext context, int index) {
|
||||||
|
final item = _items[index];
|
||||||
|
if (item is _GridSubtitleItem) {
|
||||||
|
return _buildSubtitleItem(context, item, index);
|
||||||
|
} else if (item is _GridImageItem) {
|
||||||
|
return _buildImageItem(context, item, index);
|
||||||
|
} else {
|
||||||
|
_log.severe("[_buildItem] Unsupported item type: ${item.runtimeType}");
|
||||||
|
throw StateError("Unsupported item type: ${item.runtimeType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubtitleItem(
|
||||||
|
BuildContext context, _GridSubtitleItem item, int index) {
|
||||||
|
return Align(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: Text(
|
||||||
|
item.subtitle,
|
||||||
|
style: Theme.of(context).textTheme.caption.copyWith(
|
||||||
|
color: Theme.of(context).textTheme.bodyText1.color,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageItem(BuildContext context, _GridImageItem item, int index) {
|
||||||
|
return ImageGridItem(
|
||||||
|
account: widget.account,
|
||||||
|
imageUrl: item.previewUrl,
|
||||||
|
isSelected: _selectedItems.contains(item),
|
||||||
|
onTap: () => _onItemTap(item, index),
|
||||||
|
onLongPress:
|
||||||
|
_isSelectionMode ? null : () => _onItemLongPress(item, index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStateChange(BuildContext context, ScanDirBlocState state) {
|
||||||
|
if (state is ScanDirBlocInit) {
|
||||||
|
_items.clear();
|
||||||
|
} else if (state is ScanDirBlocSuccess || state is ScanDirBlocLoading) {
|
||||||
|
_transformItems(state.files);
|
||||||
|
} else if (state is ScanDirBlocFailure) {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(exception_util.toUserString(state.exception, context)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
} else if (state is ScanDirBlocInconsistent) {
|
||||||
|
_reqQuery(widget.account.roots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItemTap(_GridFileItem item, int index) {
|
||||||
|
if (_isSelectionMode) {
|
||||||
|
if (!_items.contains(item)) {
|
||||||
|
_log.warning("[_onItemTap] Item not found in backing list, ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_selectedItems.contains(item)) {
|
||||||
|
// unselect
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.remove(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// select
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.add(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final fileIndex = _itemIndexToFileIndex(index);
|
||||||
|
Navigator.pushNamed(context, Viewer.routeName,
|
||||||
|
arguments: ViewerArguments(widget.account, _backingFiles, fileIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItemLongPress(_GridFileItem item, int index) {
|
||||||
|
if (!_items.contains(item)) {
|
||||||
|
_log.warning(
|
||||||
|
"[_onItemLongPress] Item not found in backing list, ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.add(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSelectionAppBarAddToAlbumPressed(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlbumPickerDialog(
|
||||||
|
account: widget.account,
|
||||||
|
),
|
||||||
|
).then((value) {
|
||||||
|
if (value == null) {
|
||||||
|
// user cancelled the dialog
|
||||||
|
} else if (value is Album) {
|
||||||
|
_log.info("[_onSelectionAppBarAddToAlbumPressed] Album picked: $value");
|
||||||
|
_addSelectedToAlbum(context, value).then((_) {
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.clear();
|
||||||
|
});
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context)
|
||||||
|
.addSelectedToAlbumSuccessNotification(value.name)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
}).catchError((_) {});
|
||||||
|
} else {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context)
|
||||||
|
.addSelectedToAlbumFailureNotification),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}).catchError((e, stacktrace) {
|
||||||
|
_log.severe(
|
||||||
|
"[_onSelectionAppBarAddToAlbumPressed] Failed while showDialog",
|
||||||
|
e,
|
||||||
|
stacktrace);
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(
|
||||||
|
"${AppLocalizations.of(context).addSelectedToAlbumFailureNotification}: "
|
||||||
|
"${exception_util.toUserString(e, context)}"),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addSelectedToAlbum(BuildContext context, Album album) async {
|
||||||
|
final selectedItems = _selectedItems
|
||||||
|
.whereType<_GridFileItem>()
|
||||||
|
.map((e) => AlbumFileItem(file: e.file))
|
||||||
|
.toList();
|
||||||
|
try {
|
||||||
|
final albumRepo = AlbumRepo(AlbumCachedDataSource());
|
||||||
|
await UpdateAlbum(albumRepo)(
|
||||||
|
widget.account,
|
||||||
|
album.copyWith(
|
||||||
|
items: [
|
||||||
|
...album.items,
|
||||||
|
...selectedItems,
|
||||||
|
],
|
||||||
|
));
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.severe(
|
||||||
|
"[_addSelectedToAlbum] Failed while updating album", e, stacktrace);
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(
|
||||||
|
"${AppLocalizations.of(context).addSelectedToAlbumFailureNotification}: "
|
||||||
|
"${exception_util.toUserString(e, context)}"),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSelectionAppBarDeletePressed(BuildContext context) async {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context)
|
||||||
|
.deleteSelectedProcessingNotification(_selectedItems.length)),
|
||||||
|
duration: k.snackBarDurationShort,
|
||||||
|
));
|
||||||
|
final selectedFiles =
|
||||||
|
_selectedItems.whereType<_GridFileItem>().map((e) => e.file).toList();
|
||||||
|
setState(() {
|
||||||
|
_selectedItems.clear();
|
||||||
|
});
|
||||||
|
final fileRepo = FileRepo(FileCachedDataSource());
|
||||||
|
final albumRepo = AlbumRepo(AlbumCachedDataSource());
|
||||||
|
final failures = <File>[];
|
||||||
|
for (final f in selectedFiles) {
|
||||||
|
try {
|
||||||
|
await Remove(fileRepo, albumRepo)(widget.account, f);
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
_log.severe(
|
||||||
|
"[_onSelectionAppBarDeletePressed] Failed while removing file: ${f.path}",
|
||||||
|
e,
|
||||||
|
stacktrace);
|
||||||
|
failures.add(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.isEmpty) {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(
|
||||||
|
AppLocalizations.of(context).deleteSelectedSuccessNotification),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context)
|
||||||
|
.deleteSelectedFailureNotification(failures.length)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transform a File list to grid items
|
||||||
|
void _transformItems(List<File> files) {
|
||||||
|
_backingFiles = files
|
||||||
|
.where((element) => file_util.isSupportedFormat(element))
|
||||||
|
.sorted(compareFileDateTimeDescending);
|
||||||
|
|
||||||
|
_items.clear();
|
||||||
|
String currentDateStr;
|
||||||
|
for (final f in _backingFiles) {
|
||||||
|
final newDateStr = (f.metadata?.exif?.dateTimeOriginal ?? f.lastModified)
|
||||||
|
?.toSubtitleString();
|
||||||
|
if (newDateStr != currentDateStr) {
|
||||||
|
_items.add(_GridItem.subtitle(newDateStr));
|
||||||
|
currentDateStr = newDateStr;
|
||||||
|
}
|
||||||
|
var previewUrl;
|
||||||
|
if (f.hasPreview) {
|
||||||
|
previewUrl = api_util.getFilePreviewUrl(widget.account, f,
|
||||||
|
width: _thumbSize, height: _thumbSize);
|
||||||
|
} else {
|
||||||
|
previewUrl = api_util.getFileUrl(widget.account, f);
|
||||||
|
}
|
||||||
|
_items.add(_GridItem.image(f, previewUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
_transformSelectedItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map selected items to the new item list
|
||||||
|
void _transformSelectedItems() {
|
||||||
|
final newSelectedItems = _selectedItems
|
||||||
|
.map((from) {
|
||||||
|
try {
|
||||||
|
return _items
|
||||||
|
.whereType<_GridFileItem>()
|
||||||
|
.firstWhere((to) => from.file.path == to.file.path);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.where((element) => element != null)
|
||||||
|
.toList();
|
||||||
|
_selectedItems
|
||||||
|
..clear()
|
||||||
|
..addAll(newSelectedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a grid item index to its corresponding file index
|
||||||
|
///
|
||||||
|
/// These two indices differ when there's non file-based item on screen
|
||||||
|
int _itemIndexToFileIndex(int itemIndex) {
|
||||||
|
var fileIndex = 0;
|
||||||
|
final itemIt = _items.iterator;
|
||||||
|
for (int i = 0; i < itemIndex; ++i) {
|
||||||
|
if (!itemIt.moveNext()) {
|
||||||
|
// ???
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (itemIt.current is _GridFileItem) {
|
||||||
|
++fileIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fileIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reqQuery(List<String> roots) {
|
||||||
|
_bloc.add(ScanDirBlocQuery(
|
||||||
|
widget.account,
|
||||||
|
roots
|
||||||
|
.map((e) => File(
|
||||||
|
path:
|
||||||
|
"${api_util.getWebdavRootUrlRelative(widget.account)}/$e"))
|
||||||
|
.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
int get _thumbSize {
|
||||||
|
switch (_thumbZoomLevel) {
|
||||||
|
case 1:
|
||||||
|
return 176;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
return 256;
|
||||||
|
|
||||||
|
case 0:
|
||||||
|
default:
|
||||||
|
return 112;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isSelectionMode => _selectedItems.isNotEmpty;
|
||||||
|
|
||||||
|
ScanDirBloc _bloc;
|
||||||
|
|
||||||
|
final _items = <_GridItem>[];
|
||||||
|
var _backingFiles = <File>[];
|
||||||
|
|
||||||
|
var _thumbZoomLevel = 0;
|
||||||
|
|
||||||
|
final _selectedItems = <_GridFileItem>[];
|
||||||
|
|
||||||
|
static final _log = Logger("widget.home_photos._HomePhotosState");
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _GridItem {
|
||||||
|
const _GridItem();
|
||||||
|
|
||||||
|
factory _GridItem.subtitle(String val) => _GridSubtitleItem(val);
|
||||||
|
|
||||||
|
factory _GridItem.image(File file, String previewUrl) =>
|
||||||
|
_GridImageItem(file, previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GridSubtitleItem extends _GridItem {
|
||||||
|
_GridSubtitleItem(this.subtitle);
|
||||||
|
|
||||||
|
final String subtitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _GridFileItem extends _GridItem {
|
||||||
|
_GridFileItem(this.file);
|
||||||
|
|
||||||
|
final File file;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GridImageItem extends _GridFileItem {
|
||||||
|
_GridImageItem(File file, this.previewUrl) : super(file);
|
||||||
|
|
||||||
|
final String previewUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on DateTime {
|
||||||
|
String toSubtitleString() {
|
||||||
|
final format = DateFormat(DateFormat.YEAR_MONTH_DAY);
|
||||||
|
return format.format(this.toLocal());
|
||||||
|
}
|
||||||
|
}
|
74
lib/widget/image_grid_item.dart
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api.dart';
|
||||||
|
import 'package:nc_photos/theme.dart';
|
||||||
|
|
||||||
|
class ImageGridItem extends StatelessWidget {
|
||||||
|
ImageGridItem({
|
||||||
|
Key key,
|
||||||
|
@required this.account,
|
||||||
|
@required this.imageUrl,
|
||||||
|
this.isSelected = false,
|
||||||
|
this.onTap,
|
||||||
|
this.onLongPress,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: FittedBox(
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
httpHeaders: {
|
||||||
|
"Authorization": Api.getAuthorizationHeaderValue(account),
|
||||||
|
},
|
||||||
|
fadeInDuration: const Duration(),
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelected)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
color: AppTheme.getSelectionOverlayColor(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelected)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(
|
||||||
|
Icons.check_circle_outlined,
|
||||||
|
size: 32,
|
||||||
|
color: AppTheme.getSelectionCheckColor(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final String imageUrl;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
}
|
169
lib/widget/my_app.dart
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
|
import 'package:nc_photos/theme.dart';
|
||||||
|
import 'package:nc_photos/widget/album_viewer.dart';
|
||||||
|
import 'package:nc_photos/widget/connect.dart';
|
||||||
|
import 'package:nc_photos/widget/home.dart';
|
||||||
|
import 'package:nc_photos/widget/root_picker.dart';
|
||||||
|
import 'package:nc_photos/widget/settings.dart';
|
||||||
|
import 'package:nc_photos/widget/setup.dart';
|
||||||
|
import 'package:nc_photos/widget/sign_in.dart';
|
||||||
|
import 'package:nc_photos/widget/splash.dart';
|
||||||
|
import 'package:nc_photos/widget/viewer.dart';
|
||||||
|
|
||||||
|
abstract class MyApp extends StatelessWidget implements SnackBarHandler {
|
||||||
|
MyApp() {
|
||||||
|
SnackBarManager().registerHandler(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
||||||
|
theme: _getLightTheme(),
|
||||||
|
// darkTheme: _getDarkTheme(),
|
||||||
|
initialRoute: Splash.routeName,
|
||||||
|
onGenerateRoute: _onGenerateRoute,
|
||||||
|
scaffoldMessengerKey: _scaffoldMessengerKey,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
showSnackBar(SnackBar snackBar) =>
|
||||||
|
_scaffoldMessengerKey.currentState?.showSnackBar(snackBar);
|
||||||
|
|
||||||
|
ThemeData _getLightTheme() => ThemeData(
|
||||||
|
brightness: Brightness.light,
|
||||||
|
primarySwatch: AppTheme.primarySwatchLight,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ThemeData _getDarkTheme() => ThemeData(
|
||||||
|
// brightness: Brightness.dark,
|
||||||
|
// primarySwatch: AppTheme.primarySwatchDark,
|
||||||
|
// );
|
||||||
|
|
||||||
|
Map<String, WidgetBuilder> _getRouter() => {
|
||||||
|
Setup.routeName: (context) => Setup(),
|
||||||
|
SignIn.routeName: (context) => SignIn(),
|
||||||
|
Splash.routeName: (context) => Splash(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Route<dynamic> _onGenerateRoute(RouteSettings settings) {
|
||||||
|
_log.info("[_onGenerateRoute] Route: ${settings.name}");
|
||||||
|
Route<dynamic> route;
|
||||||
|
route ??= _handleBasicRoute(settings);
|
||||||
|
route ??= _handleViewerRoute(settings);
|
||||||
|
route ??= _handleConnectRoute(settings);
|
||||||
|
route ??= _handleHomeRoute(settings);
|
||||||
|
route ??= _handleRootPickerRoute(settings);
|
||||||
|
route ??= _handleAlbumViewerRoute(settings);
|
||||||
|
route ??= _handleSettingsRoute(settings);
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route<dynamic> _handleBasicRoute(RouteSettings settings) {
|
||||||
|
for (final e in _getRouter().entries) {
|
||||||
|
if (e.key == settings.name) {
|
||||||
|
return MaterialPageRoute(
|
||||||
|
builder: e.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route<dynamic> _handleViewerRoute(RouteSettings settings) {
|
||||||
|
try {
|
||||||
|
if (settings.name == Viewer.routeName && settings.arguments != null) {
|
||||||
|
final ViewerArguments args = settings.arguments;
|
||||||
|
return MaterialPageRoute(
|
||||||
|
builder: (context) => Viewer.fromArgs(args),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[_handleViewerRoute] Failed while handling route", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route<dynamic> _handleConnectRoute(RouteSettings settings) {
|
||||||
|
try {
|
||||||
|
if (settings.name == Connect.routeName && settings.arguments != null) {
|
||||||
|
final ConnectArguments args = settings.arguments;
|
||||||
|
return MaterialPageRoute(
|
||||||
|
builder: (context) => Connect.fromArgs(args),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[_handleConnectRoute] Failed while handling route", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route<dynamic> _handleHomeRoute(RouteSettings settings) {
|
||||||
|
try {
|
||||||
|
if (settings.name == Home.routeName && settings.arguments != null) {
|
||||||
|
final HomeArguments args = settings.arguments;
|
||||||
|
return MaterialPageRoute(
|
||||||
|
builder: (context) => Home.fromArgs(args),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[_handleHomeRoute] Failed while handling route", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route<dynamic> _handleRootPickerRoute(RouteSettings settings) {
|
||||||
|
try {
|
||||||
|
if (settings.name == RootPicker.routeName && settings.arguments != null) {
|
||||||
|
final RootPickerArguments args = settings.arguments;
|
||||||
|
return MaterialPageRoute(
|
||||||
|
builder: (context) => RootPicker.fromArgs(args),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[_handleRootPickerRoute] Failed while handling route", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route<dynamic> _handleAlbumViewerRoute(RouteSettings settings) {
|
||||||
|
try {
|
||||||
|
if (settings.name == AlbumViewer.routeName &&
|
||||||
|
settings.arguments != null) {
|
||||||
|
final AlbumViewerArguments args = settings.arguments;
|
||||||
|
return MaterialPageRoute(
|
||||||
|
builder: (context) => AlbumViewer.fromArgs(args),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[_handleAlbumViewerRoute] Failed while handling route", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route<dynamic> _handleSettingsRoute(RouteSettings settings) {
|
||||||
|
try {
|
||||||
|
if (settings.name == Settings.routeName && settings.arguments != null) {
|
||||||
|
final SettingsArguments args = settings.arguments;
|
||||||
|
return MaterialPageRoute(
|
||||||
|
builder: (context) => Settings.fromArgs(args),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[_handleSettingsRoute] Failed while handling route", e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
|
|
||||||
|
static final _log = Logger("widget.my_app.MyApp");
|
||||||
|
}
|
85
lib/widget/new_album_dialog.dart
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/entity/album.dart';
|
||||||
|
import 'package:nc_photos/use_case/create_album.dart';
|
||||||
|
|
||||||
|
/// Dialog to create a new album
|
||||||
|
///
|
||||||
|
/// The created album will be popped to the previous route, or null if user
|
||||||
|
/// cancelled
|
||||||
|
class NewAlbumDialog extends StatefulWidget {
|
||||||
|
NewAlbumDialog({
|
||||||
|
Key key,
|
||||||
|
@required this.account,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _NewAlbumDialogState();
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NewAlbumDialogState extends State<NewAlbumDialog> {
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(AppLocalizations.of(context).createAlbumTooltip),
|
||||||
|
content: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: TextFormField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: AppLocalizations.of(context).nameInputHint,
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value.isEmpty) {
|
||||||
|
return AppLocalizations.of(context).albumNameInputInvalidEmpty;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onSaved: (value) {
|
||||||
|
_formValue.name = value;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _onOkPressed(context),
|
||||||
|
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onOkPressed(BuildContext context) {
|
||||||
|
if (_formKey.currentState.validate()) {
|
||||||
|
_formKey.currentState.save();
|
||||||
|
final album = Album(
|
||||||
|
name: _formValue.name,
|
||||||
|
items: const [],
|
||||||
|
);
|
||||||
|
_log.info("[_onOkPressed] Creating album: $album");
|
||||||
|
final albumRepo = AlbumRepo(AlbumCachedDataSource());
|
||||||
|
final newAlbum = CreateAlbum(albumRepo)(widget.account, album);
|
||||||
|
// let previous route to handle this future
|
||||||
|
Navigator.of(context).pop(newAlbum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
final _formValue = _FormValue();
|
||||||
|
|
||||||
|
static final _log = Logger("widget.new_album_dialog._AlbumPickerDialogState");
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FormValue {
|
||||||
|
String name;
|
||||||
|
}
|
48
lib/widget/popup_menu_zoom.dart
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class PopupMenuZoom extends PopupMenuEntry<void> {
|
||||||
|
PopupMenuZoom({
|
||||||
|
Key key,
|
||||||
|
@required this.initialValue,
|
||||||
|
this.onChanged,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
represents(void value) => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _PopupMenuZoomState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
// this value doesn't seems to do anything?
|
||||||
|
final double height = 48.0;
|
||||||
|
|
||||||
|
final int initialValue;
|
||||||
|
final void Function(double) onChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PopupMenuZoomState extends State<PopupMenuZoom> {
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
_value = widget.initialValue.toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return Slider(
|
||||||
|
value: _value,
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
divisions: 2,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_value = value;
|
||||||
|
});
|
||||||
|
widget.onChanged?.call(value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var _value = 0.0;
|
||||||
|
}
|
439
lib/widget/root_picker.dart
Normal file
|
@ -0,0 +1,439 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||||
|
import 'package:nc_photos/bloc/ls_dir.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/exception_util.dart' as exception_util;
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
|
import 'package:nc_photos/theme.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
|
class RootPickerArguments {
|
||||||
|
RootPickerArguments(this.account);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RootPicker extends StatefulWidget {
|
||||||
|
static const routeName = "/root-picker";
|
||||||
|
|
||||||
|
RootPicker({
|
||||||
|
Key key,
|
||||||
|
@required this.account,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
RootPicker.fromArgs(RootPickerArguments args, {Key key})
|
||||||
|
: this(
|
||||||
|
key: key,
|
||||||
|
account: args.account,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _RootPickerState();
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RootPickerState extends State<RootPicker> {
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
_initBloc();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(BuildContext context) {
|
||||||
|
return AppTheme(
|
||||||
|
child: Scaffold(
|
||||||
|
body: BlocListener<LsDirBloc, LsDirBlocState>(
|
||||||
|
bloc: _bloc,
|
||||||
|
listener: (context, state) => _onStateChange(context, state),
|
||||||
|
child: BlocBuilder<LsDirBloc, LsDirBlocState>(
|
||||||
|
bloc: _bloc,
|
||||||
|
builder: (context, state) => _buildContent(context, state),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initBloc() {
|
||||||
|
_log.info("[_initBloc] Initialize bloc");
|
||||||
|
_bloc = LsDirBloc();
|
||||||
|
_bloc.add(LsDirBlocQuery(widget.account,
|
||||||
|
[File(path: api_util.getWebdavRootUrlRelative(widget.account))]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context, LsDirBlocState state) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context).rootPickerHeaderText,
|
||||||
|
style: Theme.of(context).textTheme.headline5,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: AlignmentDirectional.topStart,
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(context).rootPickerSubHeaderText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state is LsDirBlocLoading)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: const LinearProgressIndicator(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxWidth: AppTheme.widthLimitedContentMaxWidth),
|
||||||
|
// needed otherwise no ripple effect
|
||||||
|
child: _buildList(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxWidth: AppTheme.widthLimitedContentMaxWidth),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
if (!ModalRoute.of(context).isFirst)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _onSkipPressed(context),
|
||||||
|
child: Text(AppLocalizations.of(context).skipButtonLabel),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => _onConfirmPressed(context),
|
||||||
|
child: Text(AppLocalizations.of(context).confirmButtonLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildList(BuildContext context) {
|
||||||
|
final current = _findCurrentNavigateLevel();
|
||||||
|
final isTopLevel = _positions.isEmpty;
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
accentColor: AppTheme.getOverscrollIndicatorColor(context),
|
||||||
|
),
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: k.animationDurationNormal,
|
||||||
|
// see AnimatedSwitcher.defaultLayoutBuilder
|
||||||
|
layoutBuilder: (currentChild, previousChildren) => Stack(
|
||||||
|
children: <Widget>[
|
||||||
|
...previousChildren,
|
||||||
|
if (currentChild != null) currentChild,
|
||||||
|
],
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
),
|
||||||
|
child: ListView.separated(
|
||||||
|
key: ObjectKey(current),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (!isTopLevel && index == 0) {
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: const SizedBox(width: 24),
|
||||||
|
title: Text(
|
||||||
|
AppLocalizations.of(context).rootPickerNavigateUpItemText),
|
||||||
|
onTap: () {
|
||||||
|
try {
|
||||||
|
_navigateUp();
|
||||||
|
} catch (e) {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(exception_util.toUserString(e, context)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return _buildItem(context, current[index - (isTopLevel ? 0 : 1)]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
separatorBuilder: (context, index) => const Divider(),
|
||||||
|
itemCount: current.length + (isTopLevel ? 0 : 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildItem(BuildContext context, LsDirBlocItem item) {
|
||||||
|
final pickState = _isItemPicked(item);
|
||||||
|
|
||||||
|
IconData iconData;
|
||||||
|
switch (pickState) {
|
||||||
|
case PickState.picked:
|
||||||
|
iconData = Icons.check_box;
|
||||||
|
break;
|
||||||
|
case PickState.childPicked:
|
||||||
|
iconData = Icons.indeterminate_check_box;
|
||||||
|
break;
|
||||||
|
case PickState.notPicked:
|
||||||
|
default:
|
||||||
|
iconData = Icons.check_box_outline_blank;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: AnimatedSwitcher(
|
||||||
|
duration: k.animationDurationShort,
|
||||||
|
transitionBuilder: (child, animation) =>
|
||||||
|
ScaleTransition(child: child, scale: animation),
|
||||||
|
child: Icon(
|
||||||
|
iconData,
|
||||||
|
key: ValueKey(pickState),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
if (pickState == PickState.picked) {
|
||||||
|
_unpick(item);
|
||||||
|
} else {
|
||||||
|
_pick(item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
title: Text(path.basename(item.file.path)),
|
||||||
|
trailing:
|
||||||
|
item.children.isNotEmpty ? const Icon(Icons.arrow_forward_ios) : null,
|
||||||
|
onTap: item.children.isNotEmpty
|
||||||
|
? () {
|
||||||
|
try {
|
||||||
|
_navigateInto(item.file);
|
||||||
|
} catch (e) {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(exception_util.toUserString(e, context)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStateChange(BuildContext context, LsDirBlocState state) {
|
||||||
|
if (state is LsDirBlocSuccess) {
|
||||||
|
_positions = [];
|
||||||
|
_root = LsDirBlocItem(File(path: "/"), state.items);
|
||||||
|
} else if (state is LsDirBlocFailure) {
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(exception_util.toUserString(state.exception, context)),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSkipPressed(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
content: Text(AppLocalizations.of(context)
|
||||||
|
.rootPickerSkipConfirmationDialogContent),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
},
|
||||||
|
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)).then((value) {
|
||||||
|
if (value == true) {
|
||||||
|
// default is to include all files, so we just return the same account
|
||||||
|
Navigator.of(context).pop(widget.account);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onConfirmPressed(BuildContext context) {
|
||||||
|
final roots = _picks.map((e) => e.file.strippedPath).toList();
|
||||||
|
final newAccount = widget.account.copyWith(roots: roots);
|
||||||
|
_log.info("[_onConfirmPressed] Account is good: $newAccount");
|
||||||
|
Navigator.of(context).pop(newAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick an item
|
||||||
|
void _pick(LsDirBlocItem item) {
|
||||||
|
setState(() {
|
||||||
|
_picks.add(item);
|
||||||
|
_picks = _optimizePicks(_root);
|
||||||
|
});
|
||||||
|
_log.fine("[_pick] Picked: ${_pickListToString(_picks)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optimize the picked array
|
||||||
|
///
|
||||||
|
/// 1) If a parent directory is picked, all children will be ignored
|
||||||
|
List<LsDirBlocItem> _optimizePicks(LsDirBlocItem item) {
|
||||||
|
if (_picks.contains(item)) {
|
||||||
|
// this dir is explicitly picked, nothing more to do
|
||||||
|
return [item];
|
||||||
|
}
|
||||||
|
if (item.children.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final products = <LsDirBlocItem>[];
|
||||||
|
for (final i in item.children) {
|
||||||
|
products.addAll(_optimizePicks(i));
|
||||||
|
}
|
||||||
|
// // see if all children are being picked
|
||||||
|
// if (item != _root &&
|
||||||
|
// products.length >= item.children.length &&
|
||||||
|
// item.children.every((element) => products.contains(element))) {
|
||||||
|
// // all children are being picked, add [item] to list and remove its
|
||||||
|
// // children
|
||||||
|
// _log.fine(
|
||||||
|
// "[_optimizePicks] All children under '${item.file.path}' are being picked, optimized");
|
||||||
|
// return products
|
||||||
|
// .where((element) => !item.children.contains(element))
|
||||||
|
// .toList()
|
||||||
|
// ..add(item);
|
||||||
|
// }
|
||||||
|
return products;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpick an item
|
||||||
|
void _unpick(LsDirBlocItem item) {
|
||||||
|
setState(() {
|
||||||
|
if (_picks.contains(item)) {
|
||||||
|
// ourself is being picked, simple
|
||||||
|
_picks = _picks.where((element) => element != item).toList();
|
||||||
|
} else {
|
||||||
|
// Look for the closest picked dir
|
||||||
|
final parents = _picks
|
||||||
|
.where((element) => item.file.path.startsWith(element.file.path))
|
||||||
|
.toList()
|
||||||
|
..sort(
|
||||||
|
(a, b) => b.file.path.length.compareTo(a.file.path.length));
|
||||||
|
final parent = parents.first;
|
||||||
|
_picks.remove(parent);
|
||||||
|
_picks.addAll(_pickedAllExclude(parent, item));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_log.fine("[_unpick] Picked: ${_pickListToString(_picks)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a list where all children of [item] but [exclude] are picked
|
||||||
|
List<LsDirBlocItem> _pickedAllExclude(
|
||||||
|
LsDirBlocItem item, LsDirBlocItem exclude) {
|
||||||
|
if (item == exclude) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
_log.fine(
|
||||||
|
"[_pickedAllExclude] Unpicking '${item.file.path}' and picking children");
|
||||||
|
final products = <LsDirBlocItem>[];
|
||||||
|
for (final i in item.children) {
|
||||||
|
if (exclude.file.path.startsWith(i.file.path)) {
|
||||||
|
// [i] is a parent of exclude
|
||||||
|
products.addAll(_pickedAllExclude(i, exclude));
|
||||||
|
} else {
|
||||||
|
products.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return products;
|
||||||
|
}
|
||||||
|
|
||||||
|
PickState _isItemPicked(LsDirBlocItem item) {
|
||||||
|
var product = PickState.notPicked;
|
||||||
|
for (final p in _picks) {
|
||||||
|
// exact match, or parent is picked
|
||||||
|
if (p.file.path == item.file.path ||
|
||||||
|
item.file.path.startsWith("${p.file.path}/")) {
|
||||||
|
product = PickState.picked;
|
||||||
|
// no need to check the remaining ones
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (p.file.path.startsWith("${item.file.path}/")) {
|
||||||
|
product = PickState.childPicked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (product == PickState.childPicked) {}
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the string representation of a list of LsDirBlocItem
|
||||||
|
static _pickListToString(List<LsDirBlocItem> items) =>
|
||||||
|
"['${items.map((e) => e.file.path).join('\', \'')}']";
|
||||||
|
|
||||||
|
void _navigateInto(File file) {
|
||||||
|
final current = _findCurrentNavigateLevel();
|
||||||
|
final navPosition =
|
||||||
|
current.indexWhere((element) => element.file.path == file.path);
|
||||||
|
if (navPosition == -1) {
|
||||||
|
_log.severe("[_navigateInto] File not found: '${file.path}', "
|
||||||
|
"current level: ['${current.map((e) => e.file.path).join('\', \'')}']");
|
||||||
|
throw StateError("Can't navigate into directory");
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_positions.add(navPosition);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateUp() {
|
||||||
|
if (_positions.isEmpty) {
|
||||||
|
throw StateError("Can't navigate up in the root directory");
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_positions.removeLast();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find and return the list of items currently navigated to
|
||||||
|
List<LsDirBlocItem> _findCurrentNavigateLevel() {
|
||||||
|
var product = _root.children;
|
||||||
|
for (final i in _positions) {
|
||||||
|
product = product[i].children;
|
||||||
|
}
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
LsDirBloc _bloc;
|
||||||
|
|
||||||
|
var _root = LsDirBlocItem(File(path: "/"), const []);
|
||||||
|
|
||||||
|
/// Track where the user is navigating in [_backingFiles]
|
||||||
|
var _positions = <int>[];
|
||||||
|
var _picks = <LsDirBlocItem>[];
|
||||||
|
|
||||||
|
static final _log = Logger("widget.root_picker._RootPickerState");
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PickState {
|
||||||
|
notPicked,
|
||||||
|
picked,
|
||||||
|
childPicked,
|
||||||
|
}
|
187
lib/widget/settings.dart
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/k.dart' as k;
|
||||||
|
import 'package:nc_photos/metadata_task_manager.dart';
|
||||||
|
import 'package:nc_photos/pref.dart';
|
||||||
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
|
import 'package:nc_photos/theme.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
class SettingsArguments {
|
||||||
|
SettingsArguments(this.account);
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Settings extends StatefulWidget {
|
||||||
|
static const routeName = "/settings";
|
||||||
|
|
||||||
|
Settings({
|
||||||
|
Key key,
|
||||||
|
@required this.account,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
Settings.fromArgs(SettingsArguments args, {Key key})
|
||||||
|
: this(
|
||||||
|
account: args.account,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _SettingsState();
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsState extends State<Settings> {
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
_isEnableExif = Pref.inst().isEnableExif();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
build(context) {
|
||||||
|
return AppTheme(
|
||||||
|
child: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) => _buildContent(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context) {
|
||||||
|
final translator = AppLocalizations.of(context).translator;
|
||||||
|
return CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverAppBar(
|
||||||
|
pinned: true,
|
||||||
|
title: Text(AppLocalizations.of(context).settingsWidgetTitle),
|
||||||
|
),
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildListDelegate(
|
||||||
|
[
|
||||||
|
SwitchListTile(
|
||||||
|
title:
|
||||||
|
Text(AppLocalizations.of(context).settingsExifSupportTitle),
|
||||||
|
subtitle: _isEnableExif
|
||||||
|
? Text(AppLocalizations.of(context)
|
||||||
|
.settingsExifSupportTrueSubtitle)
|
||||||
|
: null,
|
||||||
|
value: _isEnableExif,
|
||||||
|
onChanged: (value) => _onExifSupportChanged(context, value),
|
||||||
|
),
|
||||||
|
_buildCaption(context,
|
||||||
|
AppLocalizations.of(context).settingsAboutSectionTitle),
|
||||||
|
ListTile(
|
||||||
|
title: Text(AppLocalizations.of(context).settingsVersionTitle),
|
||||||
|
subtitle: const Text(k.version),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title:
|
||||||
|
Text(AppLocalizations.of(context).settingsSourceCodeTitle),
|
||||||
|
subtitle: Text(_sourceRepo),
|
||||||
|
onTap: () async {
|
||||||
|
await launch(_sourceRepo);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title:
|
||||||
|
Text(AppLocalizations.of(context).settingsTranslatorTitle),
|
||||||
|
subtitle: Text(translator.isEmpty
|
||||||
|
? "Help translating to your language"
|
||||||
|
: translator),
|
||||||
|
onTap: () async {
|
||||||
|
await launch(_translationUrl);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCaption(BuildContext context, String label) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onExifSupportChanged(BuildContext context, bool value) {
|
||||||
|
if (value) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
AppLocalizations.of(context).exifSupportConfirmationDialogTitle),
|
||||||
|
content: Text(AppLocalizations.of(context).exifSupportDetails),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
},
|
||||||
|
child: Text(AppLocalizations.of(context).enableButtonLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).then((value) {
|
||||||
|
if (value == true) {
|
||||||
|
_setExifSupport(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_setExifSupport(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setExifSupport(bool value) {
|
||||||
|
final oldValue = _isEnableExif;
|
||||||
|
setState(() {
|
||||||
|
_isEnableExif = value;
|
||||||
|
});
|
||||||
|
Pref.inst().setEnableExif(value).then((result) {
|
||||||
|
if (result) {
|
||||||
|
if (value) {
|
||||||
|
KiwiContainer()
|
||||||
|
.resolve<MetadataTaskManager>()
|
||||||
|
.addTask(MetadataTask(widget.account));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_log.severe("[_setExifSupport] Failed writing pref");
|
||||||
|
SnackBarManager().showSnackBar(SnackBar(
|
||||||
|
content: Text(
|
||||||
|
AppLocalizations.of(context).writePreferenceFailureNotification),
|
||||||
|
duration: k.snackBarDurationNormal,
|
||||||
|
));
|
||||||
|
setState(() {
|
||||||
|
_isEnableExif = oldValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static const String _sourceRepo = "https://gitlab.com/nkming2/nc-photos";
|
||||||
|
static const String _translationUrl =
|
||||||
|
"https://gitlab.com/nkming2/nc-photos/lib/l10n";
|
||||||
|
|
||||||
|
bool _isEnableExif;
|
||||||
|
|
||||||
|
static final _log = Logger("widget.settings._SettingsState");
|
||||||
|
}
|