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");
|
||||
}
|