commit ab573ad2737b0df57f7479305aa245301c4ee34e Author: Ming Ming Date: Sat Apr 10 12:28:12 2021 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b20c6d9a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.metadata b/.metadata new file mode 100644 index 00000000..ccb00e6a --- /dev/null +++ b/.metadata @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 00000000..cc457738 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# Photos (for Nextcloud) +Photos (for Nextcloud) is a new gallery app for viewing your photos hosted on Nextcloud servers + +[Google Play](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. diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..61739d94 --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 00000000..b984909a --- /dev/null +++ b/android/app/build.gradle @@ -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" +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..9db48d2d --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# To ensure that retracing stack traces is unambiguous +-keepattributes LineNumberTable,SourceFile diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0ab9fa1e --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt b/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt new file mode 100644 index 00000000..caff3cde --- /dev/null +++ b/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt @@ -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("fileName")!!, + call.argument("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 +} diff --git a/android/app/src/main/res/drawable-anydpi-v26/ic_launcher_background.xml b/android/app/src/main/res/drawable-anydpi-v26/ic_launcher_background.xml new file mode 100644 index 00000000..2cc6b075 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi-v26/ic_launcher_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..3ba4e35c --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..cafe84bf Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..daabe3ef Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..81b92550 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..d0ad7910 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..2f63a745 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..1c077f8a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..a76e1b8f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..4ee70c4c Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..2f089a88 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..2528ce24 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..449a9f93 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..8da88ca0 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Photos + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..d74aa35c --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 00000000..c505a863 --- /dev/null +++ b/android/build.gradle @@ -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 +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..94adc3a3 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..bc6a58af --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 00000000..44e62bcf --- /dev/null +++ b/android/settings.gradle @@ -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" diff --git a/assets/2.0x/setup_hidden_pref_dir.png b/assets/2.0x/setup_hidden_pref_dir.png new file mode 100644 index 00000000..0c96fd98 Binary files /dev/null and b/assets/2.0x/setup_hidden_pref_dir.png differ diff --git a/assets/setup_hidden_pref_dir.png b/assets/setup_hidden_pref_dir.png new file mode 100644 index 00000000..1272d033 Binary files /dev/null and b/assets/setup_hidden_pref_dir.png differ diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 00000000..15338f2d --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart diff --git a/lib/account.dart b/lib/account.dart new file mode 100644 index 00000000..8f06dd0e --- /dev/null +++ b/lib/account.dart @@ -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 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 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 json) + : scheme = json["scheme"], + address = json["address"], + username = json["username"], + password = json["password"], + _roots = json["roots"].cast(); + + Map toJson() => { + "scheme": scheme, + "address": address, + "username": username, + "password": password, + "roots": _roots, + }; + + @override + List get props => [scheme, address, username, password, _roots]; + + List get roots => _roots; + + final String scheme; + final String address; + final String username; + final String password; + final List _roots; +} + +extension AccountExtension on Account { + String get url => "$scheme://$address"; +} diff --git a/lib/api/api.dart b/lib/api/api.dart new file mode 100644 index 00000000..d119640a --- /dev/null +++ b/lib/api/api.dart @@ -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 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 request( + String method, + String endpoint, { + Map 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 delete({ + @required String path, + }) async { + try { + return await _api.request("DELETE", path); + } catch (e) { + _log.severe("[delete] Failed while delete", e); + rethrow; + } + } + + Future 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 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 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 customNamespaces, + List 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 = { + "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 proppatch({ + @required String path, + Map namespaces, + Map set, + List remove, + }) async { + try { + final ns = { + "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 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"); +} diff --git a/lib/api/api_util.dart b/lib/api/api_util.dart new file mode 100644 index 00000000..5ca6d01b --- /dev/null +++ b/lib/api/api_util.dart @@ -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 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"(.*)"); + 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"); diff --git a/lib/app_db.dart b/lib/app_db.dart new file mode 100644 index 00000000..3a914962 --- /dev/null +++ b/lib/app_db.dart @@ -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 use(FutureOr 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 _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); +} diff --git a/lib/bloc/app_password_exchange.dart b/lib/bloc/app_password_exchange.dart new file mode 100644 index 00000000..605e3719 --- /dev/null +++ b/lib/bloc/app_password_exchange.dart @@ -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 { + AppPasswordExchangeBloc() : super(AppPasswordExchangeBlocInit()); + + @override + mapEventToState(AppPasswordExchangeBlocEvent event) async* { + _log.info("[mapEventToState] $event"); + if (event is AppPasswordExchangeBlocConnect) { + yield* _exchangePassword(event.account); + } + } + + Stream _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"); +} diff --git a/lib/bloc/list_album.dart b/lib/bloc/list_album.dart new file mode 100644 index 00000000..55a464b4 --- /dev/null +++ b/lib/bloc/list_album.dart @@ -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 albums; +} + +class ListAlbumBlocInit extends ListAlbumBlocState { + const ListAlbumBlocInit() : super(null, const []); +} + +class ListAlbumBlocLoading extends ListAlbumBlocState { + const ListAlbumBlocLoading(Account account, List albums) + : super(account, albums); +} + +class ListAlbumBlocSuccess extends ListAlbumBlocState { + const ListAlbumBlocSuccess(Account account, List albums) + : super(account, albums); +} + +class ListAlbumBlocFailure extends ListAlbumBlocState { + const ListAlbumBlocFailure( + Account account, List 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 albums) + : super(account, albums); +} + +class ListAlbumBloc extends Bloc { + ListAlbumBloc() : super(ListAlbumBlocInit()) { + _fileMetadataUpdatedListener = + AppEventListener(_onFileMetadataUpdatedEvent); + _albumUpdatedListener = + AppEventListener(_onAlbumUpdatedEvent); + _fileRemovedListener = + AppEventListener(_onFileRemovedEvent); + _albumCreatedListener = + AppEventListener(_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 _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 _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 _queryOffline( + ListAlbumBlocQuery ev, ListAlbumBlocState Function() getState) => + _queryWithAlbumDataSource( + ev, getState, FileAppDbDataSource(), AlbumAppDbDataSource()); + + Stream _queryOnline( + ListAlbumBlocQuery ev, ListAlbumBlocState Function() getState) => + _queryWithAlbumDataSource( + ev, getState, FileCachedDataSource(), AlbumCachedDataSource()); + + Stream _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 _fileMetadataUpdatedListener; + AppEventListener _albumUpdatedListener; + AppEventListener _fileRemovedListener; + AppEventListener _albumCreatedListener; + + static final _log = Logger("bloc.list_album.ListAlbumBloc"); +} diff --git a/lib/bloc/ls_dir.dart b/lib/bloc/ls_dir.dart new file mode 100644 index 00000000..139076d2 --- /dev/null +++ b/lib/bloc/ls_dir.dart @@ -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 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 roots; +} + +abstract class LsDirBlocState { + const LsDirBlocState(this._account, this._items); + + Account get account => _account; + List get items => _items; + + @override + toString() { + return "$runtimeType {" + "account: $account, " + "items: List {length: ${items.length}}, " + "}"; + } + + final Account _account; + final List _items; +} + +class LsDirBlocInit extends LsDirBlocState { + const LsDirBlocInit() : super(null, const []); +} + +class LsDirBlocLoading extends LsDirBlocState { + const LsDirBlocLoading(Account account, List items) + : super(account, items); +} + +class LsDirBlocSuccess extends LsDirBlocState { + const LsDirBlocSuccess(Account account, List items) + : super(account, items); +} + +class LsDirBlocFailure extends LsDirBlocState { + const LsDirBlocFailure( + Account account, List 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 { + LsDirBloc() : super(LsDirBlocInit()); + + @override + mapEventToState(LsDirBlocEvent event) async* { + _log.info("[mapEventToState] $event"); + if (event is LsDirBlocQuery) { + yield* _onEventQuery(event); + } + } + + Stream _onEventQuery(LsDirBlocQuery ev) async* { + try { + yield LsDirBlocLoading(ev.account, state.items); + + final products = []; + 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> _query(LsDirBlocQuery ev, File root) async { + final products = []; + 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"); +} diff --git a/lib/bloc/scan_dir.dart b/lib/bloc/scan_dir.dart new file mode 100644 index 00000000..4201d3cd --- /dev/null +++ b/lib/bloc/scan_dir.dart @@ -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 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 get files => _files; + + @override + toString() { + return "$runtimeType {" + "account: $account, " + "files: List {length: ${files.length}}, " + "}"; + } + + final Account _account; + final List _files; +} + +class ScanDirBlocInit extends ScanDirBlocState { + const ScanDirBlocInit() : super(null, const []); +} + +class ScanDirBlocLoading extends ScanDirBlocState { + const ScanDirBlocLoading(Account account, List files) + : super(account, files); +} + +class ScanDirBlocSuccess extends ScanDirBlocState { + const ScanDirBlocSuccess(Account account, List files) + : super(account, files); +} + +class ScanDirBlocFailure extends ScanDirBlocState { + const ScanDirBlocFailure(Account account, List 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 files) + : super(account, files); +} + +/// A bloc that return all files under a dir recursively +/// +/// See [ScanDir] +class ScanDirBloc extends Bloc { + ScanDirBloc() : super(ScanDirBlocInit()) { + _fileRemovedEventListener = + AppEventListener(_onFileRemovedEvent); + _fileMetadataUpdatedEventListener = + AppEventListener(_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 _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 _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 _queryOffline( + ScanDirBlocQuery ev, ScanDirBlocState Function() getState) => + _queryWithFileDataSource(ev, getState, FileAppDbDataSource()); + + Stream _queryOnline( + ScanDirBlocQuery ev, ScanDirBlocState Function() getState) => + _queryWithFileDataSource(ev, getState, FileCachedDataSource()); + + Stream _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 _fileRemovedEventListener; + AppEventListener _fileMetadataUpdatedEventListener; + + static final _log = Logger("bloc.scan_dir.ScanDirBloc"); +} diff --git a/lib/cache_manager_util.dart b/lib/cache_manager_util.dart new file mode 100644 index 00000000..823fedee --- /dev/null +++ b/lib/cache_manager_util.dart @@ -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 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; +} diff --git a/lib/connectivity_util.dart b/lib/connectivity_util.dart new file mode 100644 index 00000000..6514f8b1 --- /dev/null +++ b/lib/connectivity_util.dart @@ -0,0 +1,11 @@ +import 'package:connectivity/connectivity.dart'; + +Future waitUntilWifi() async { + while (true) { + final result = await Connectivity().checkConnectivity(); + if (result == ConnectivityResult.wifi) { + return; + } + await Future.delayed(const Duration(seconds: 5)); + } +} diff --git a/lib/double_extension.dart b/lib/double_extension.dart new file mode 100644 index 00000000..902dae22 --- /dev/null +++ b/lib/double_extension.dart @@ -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; + } + } +} diff --git a/lib/entity/album.dart b/lib/entity/album.dart new file mode 100644 index 00000000..0375218d --- /dev/null +++ b/lib/entity/album.dart @@ -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 json) { + final type = json["type"]; + final content = json["content"]; + switch (type) { + case AlbumFileItem._type: + return AlbumFileItem.fromJson(content.cast()); + default: + _log.severe("[fromJson] Unknown type: $type"); + throw ArgumentError.value(type, "type"); + } + } + + Map toJson() { + String getType() { + if (this is AlbumFileItem) { + return AlbumFileItem._type; + } else { + throw StateError("Unknwon subtype"); + } + } + + return { + "type": getType(), + "content": toContentJson(), + }; + } + + Map toContentJson(); + + static final _log = Logger("entity.album.AlbumItem"); +} + +class AlbumFileItem extends AlbumItem { + AlbumFileItem({this.file}); + + factory AlbumFileItem.fromJson(Map json) { + return AlbumFileItem( + file: File.fromJson(json["file"].cast()), + ); + } + + @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 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 items, + File albumFile, + }) { + // there's only one version right now + return Album( + lastUpdated: lastUpdated, + name: name, + items: items, + albumFile: albumFile, + ); + } + + factory Album.fromJson(Map 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())) + .toList(), + albumFile: json["albumFile"] == null + ? null + : File.fromJson(json["albumFile"].cast()), + ); + } + + @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 items, + File albumFile, + }) { + return Album( + lastUpdated: lastUpdated, + name: name ?? this.name, + items: items ?? this.items, + albumFile: albumFile ?? this.albumFile, + ); + } + + Map _toRemoteJson() { + return { + "version": version, + "lastUpdated": lastUpdated.toIso8601String(), + "name": name, + "items": items.map((e) => e.toJson()).toList(), + // ignore albumFile + }; + } + + Map _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 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 get(Account account, File albumFile) => + this.dataSrc.get(account, albumFile); + + /// See [AlbumDataSource.create] + Future create(Account account, Album album) => + this.dataSrc.create(account, album); + + /// See [AlbumDataSource.update] + Future update(Account account, Album album) => + this.dataSrc.update(account, album); + + /// See [AlbumDataSource.cleanUp] + Future cleanUp(Account account, List albumFiles) => + this.dataSrc.cleanUp(account, albumFiles); + + final AlbumDataSource dataSrc; +} + +abstract class AlbumDataSource { + /// Return the album defined by [albumFile] + Future get(Account account, File albumFile); + + // Create a new album + Future create(Account account, Album album); + + /// Update an album + Future 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 cleanUp(Account account, List 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 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 _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()); + } 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 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 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 _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}"; diff --git a/lib/entity/exif.dart b/lib/entity/exif.dart new file mode 100644 index 00000000..02165bf6 --- /dev/null +++ b/lib/entity/exif.dart @@ -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 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) { + jsonValue = value.map((e) => e.toJson()).toList(); + } else { + jsonValue = value; + } + return MapEntry(key, jsonValue); + }); + } + + factory Exif.fromJson(Map 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()); + } else if (value is List) { + exifValue = value + .map((e) => Rational.fromJson(e.cast())) + .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 data; +} diff --git a/lib/entity/file.dart b/lib/entity/file.dart new file mode 100644 index 00000000..633f1975 --- /dev/null +++ b/lib/entity/file.dart @@ -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 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()), + ); + } + + Map 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 call(Map json); +} + +/// Upgrade v1 Metadata to v2 +class MetadataUpgraderV1 implements MetadataUpgrader { + MetadataUpgraderV1({ + @required this.fileContentType, + }); + + Map call(Map 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 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(), + 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 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(Account account, File root) => + this.dataSrc.list(account, root); + + /// See [FileDataSource.remove] + Future remove(Account account, File file) => + this.dataSrc.remove(account, file); + + /// See [FileDataSource.getBinary] + Future getBinary(Account account, File file) => + this.dataSrc.getBinary(account, file); + + /// See [FileDataSource.putBinary] + Future putBinary(Account account, String path, Uint8List content) => + this.dataSrc.putBinary(account, path, content); + + /// See [FileDataSource.updateMetadata] + Future 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(Account account, File f); + + /// Remove file + Future remove(Account account, File f); + + /// Read file as binary array + Future getBinary(Account account, File f); + + /// Upload content to [path] + Future 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 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> _doList(ObjectStore store, Account account, File f) async { + final List result = await store.getObject("${_getCacheKey(account, f)}"); + if (result != null) { + return result + .cast>() + .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 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 _cacheResult(Account account, File f, List 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 _cleanUpCachedList( + Account account, List remoteResults, List 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}"; diff --git a/lib/entity/file_util.dart b/lib/entity/file_util.dart new file mode 100644 index 00000000..f76db2bc --- /dev/null +++ b/lib/entity/file_util.dart @@ -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", +]; diff --git a/lib/entity/webdav_response_parser.dart b/lib/entity/webdav_response_parser.dart new file mode 100644 index 00000000..b0266cf8 --- /dev/null +++ b/lib/entity/webdav_response_parser.dart @@ -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 call(XmlDocument xml) { + _namespaces = _parseNamespaces(xml); + final body = () { + try { + return xml.children.whereType().firstWhere((element) => + element.matchQualifiedName("multistatus", + prefix: "DAV:", namespaces: _namespaces)); + } catch (_) { + _log.severe("[call] Missing element: multistatus"); + rethrow; + } + }(); + return body.children + .whereType() + .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 get namespaces => _namespaces; + + Map _parseNamespaces(XmlDocument xml) { + final namespaces = {}; + final xmlContent = xml.descendants.whereType().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 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()) { + 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() + .firstWhere((element) => element.matchQualifiedName("status", + prefix: "DAV:", namespaces: _namespaces)) + .innerText; + if (!status.contains(" 200 ")) { + continue; + } + final prop = child.children.whereType().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 _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 = {}; + for (final e in element.children.whereType()) { + final entry = _xmlElementToMapEntry(e); + value[entry.key] = entry.value; + } + return MapEntry(key, value); + } + } + + var _namespaces = {}; + + static final _log = + Logger("entity.webdav_response_parser.WebdavResponseParser"); +} + +class _PropParser { + _PropParser({this.namespaces = const {}}); + + /// Parse element contents + void parse(XmlElement element) { + for (final child in element.children.whereType()) { + 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().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()) { + 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 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 namespaces, + }) { + final localNamespaces = {}; + 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)); + } +} diff --git a/lib/event/event.dart b/lib/event/event.dart new file mode 100644 index 00000000..5044a333 --- /dev/null +++ b/lib/event/event.dart @@ -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 { + 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().on(); + StreamSubscription _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; +} diff --git a/lib/exception.dart b/lib/exception.dart new file mode 100644 index 00000000..b0179f7f --- /dev/null +++ b/lib/exception.dart @@ -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; +} diff --git a/lib/exception_util.dart b/lib/exception_util.dart new file mode 100644 index 00000000..473dcae8 --- /dev/null +++ b/lib/exception_util.dart @@ -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(); +} diff --git a/lib/image_size_getter_util.dart b/lib/image_size_getter_util.dart new file mode 100644 index 00000000..d574fdba --- /dev/null +++ b/lib/image_size_getter_util.dart @@ -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(); +} diff --git a/lib/iterable_extension.dart b/lib/iterable_extension.dart new file mode 100644 index 00000000..fbe9ce42 --- /dev/null +++ b/lib/iterable_extension.dart @@ -0,0 +1,15 @@ +extension IterableExtension on Iterable { + /// Return a new sorted list + List 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 mapWithIndex(U fn(int index, T element)) sync* { + int i = 0; + for (final e in this) { + yield fn(i++, e); + } + } +} diff --git a/lib/k.dart b/lib/k.dart new file mode 100644 index 00000000..add36b59 --- /dev/null +++ b/lib/k.dart @@ -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); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 00000000..5d75cf9e --- /dev/null +++ b/lib/l10n/app_en.arb @@ -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" + } +} \ No newline at end of file diff --git a/lib/list_extension.dart b/lib/list_extension.dart new file mode 100644 index 00000000..f705ab0b --- /dev/null +++ b/lib/list_extension.dart @@ -0,0 +1,9 @@ +extension ListExtension on List { + /// Return a new list with only distinct elements + List distinct() { + final s = Set(); + return this.where((element) => s.add(element)).toList(); + } + + Iterable takeIndex(List indexes) => indexes.map((e) => this[e]); +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 00000000..f93c55e7 --- /dev/null +++ b/lib/main.dart @@ -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 _initPref() => Pref.init(); + +void _initBloc() { + Bloc.observer = _BlocObserver(); +} + +void _initKiwi() { + final kiwi = KiwiContainer(); + kiwi.registerInstance(EventBus()); + kiwi.registerInstance(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"); +} diff --git a/lib/metadata_task_manager.dart b/lib/metadata_task_manager.dart new file mode 100644 index 00000000..49d8628a --- /dev/null +++ b/lib/metadata_task_manager.dart @@ -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 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.broadcast(); + + static final _log = Logger("metadata_task_manager.MetadataTaskManager"); +} diff --git a/lib/mobile/android/media_store.dart b/lib/mobile/android/media_store.dart new file mode 100644 index 00000000..b08aead0 --- /dev/null +++ b/lib/mobile/android/media_store.dart @@ -0,0 +1,18 @@ +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; + +class MediaStore { + static const exceptionCodePermissionError = "permissionError"; + + static Future saveFileToDownload( + String fileName, Uint8List fileContent) async { + await _channel.invokeMethod("saveFileToDownload", { + "fileName": fileName, + "content": fileContent, + }); + } + + static const _channel = + const MethodChannel("com.nkming.nc_photos/media_store"); +} diff --git a/lib/mobile/downloader.dart b/lib/mobile/downloader.dart new file mode 100644 index 00000000..6a25a5f8 --- /dev/null +++ b/lib/mobile/downloader.dart @@ -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 _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; + } + } + } +} diff --git a/lib/mobile/metadata_loader.dart b/lib/mobile/metadata_loader.dart new file mode 100644 index 00000000..597cbae9 --- /dev/null +++ b/lib/mobile/metadata_loader.dart @@ -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> _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"); +} diff --git a/lib/mobile/my_app.dart b/lib/mobile/my_app.dart new file mode 100644 index 00000000..75f9abb2 --- /dev/null +++ b/lib/mobile/my_app.dart @@ -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); +} diff --git a/lib/mobile/platform.dart b/lib/mobile/platform.dart new file mode 100644 index 00000000..b2817d62 --- /dev/null +++ b/lib/mobile/platform.dart @@ -0,0 +1,3 @@ +export 'downloader.dart'; +export 'metadata_loader.dart'; +export 'my_app.dart'; diff --git a/lib/platform/downloader.dart b/lib/platform/downloader.dart new file mode 100644 index 00000000..5f11d6a4 --- /dev/null +++ b/lib/platform/downloader.dart @@ -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 downloadFile(Account account, File file); +} diff --git a/lib/platform/metadata_loader.dart b/lib/platform/metadata_loader.dart new file mode 100644 index 00000000..9a5dd9c9 --- /dev/null +++ b/lib/platform/metadata_loader.dart @@ -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> 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> loadNewFile(Account account, File file); + + /// Load metadata for [file], either from cache or a new download + Future> loadFile(Account account, File file); + + void cancel(); +} diff --git a/lib/pref.dart b/lib/pref.dart new file mode 100644 index 00000000..57e62f10 --- /dev/null +++ b/lib/pref.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:nc_photos/account.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class Pref { + static Future init() async { + return SharedPreferences.getInstance().then((pref) { + _inst._pref = pref; + }); + } + + factory Pref.inst() => _inst; + + List getAccounts([List def]) { + final jsonObjs = _pref.getStringList("accounts"); + return jsonObjs?.map((e) => Account.fromJson(jsonDecode(e)))?.toList() ?? + def; + } + + Future setAccounts(List value) { + final jsons = value.map((e) => jsonEncode(e.toJson())).toList(); + return _pref.setStringList("accounts", jsons); + } + + int getCurrentAccountIndex([int def]) => + _pref.getInt("currentAccountIndex") ?? def; + + Future setCurrentAccountIndex(int value) => + _pref.setInt("currentAccountIndex", value); + + int getHomePhotosZoomLevel([int def]) => + _pref.getInt("homePhotosZoomLevel") ?? def; + + Future setHomePhotosZoomLevel(int value) => + _pref.setInt("homePhotosZoomLevel", value); + + int getAlbumViewerZoomLevel([int def]) => + _pref.getInt("albumViewerZoomLevel") ?? def; + + Future setAlbumViewerZoomLevel(int value) => + _pref.setInt("albumViewerZoomLevel", value); + + bool isEnableExif([bool def = true]) => _pref.getBool("isEnableExif") ?? def; + + Future setEnableExif(bool value) => + _pref.setBool("isEnableExif", value); + + int getSetupProgress([int def = 0]) => _pref.getInt("setupProgress") ?? def; + + Future 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; + } + } +} diff --git a/lib/snack_bar_manager.dart b/lib/snack_bar_manager.dart new file mode 100644 index 00000000..8e7790b5 --- /dev/null +++ b/lib/snack_bar_manager.dart @@ -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 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 = []; + + static final _inst = SnackBarManager._(); + + final _log = Logger("snack_bar_manager.SnackBarManager"); +} + +abstract class SnackBarHandler { + ScaffoldFeatureController showSnackBar( + SnackBar snackBar); +} diff --git a/lib/string_extension.dart b/lib/string_extension.dart new file mode 100644 index 00000000..c3f628cb --- /dev/null +++ b/lib/string_extension.dart @@ -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); + } +} diff --git a/lib/theme.dart b/lib/theme.dart new file mode 100644 index 00000000..dcca32e4 --- /dev/null +++ b/lib/theme.dart @@ -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; +} diff --git a/lib/use_case/create_album.dart b/lib/use_case/create_album.dart new file mode 100644 index 00000000..853bb874 --- /dev/null +++ b/lib/use_case/create_album.dart @@ -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 call(Account account, Album album) async { + final newAlbum = await albumRepo.create(account, album); + KiwiContainer() + .resolve() + .fire(AlbumCreatedEvent(account, newAlbum)); + return newAlbum; + } + + final AlbumRepo albumRepo; +} diff --git a/lib/use_case/get_file_binary.dart b/lib/use_case/get_file_binary.dart new file mode 100644 index 00000000..eb046dc5 --- /dev/null +++ b/lib/use_case/get_file_binary.dart @@ -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 call(Account account, File file) => + fileRepo.getBinary(account, file); + + final FileRepo fileRepo; +} diff --git a/lib/use_case/list_album.dart b/lib/use_case/list_album.dart new file mode 100644 index 00000000..76f97688 --- /dev/null +++ b/lib/use_case/list_album.dart @@ -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> call(Account account) async { + try { + final albumFiles = await Ls(fileRepo)( + account, + File( + path: getAlbumFileRoot(account), + )); + final albums = []; + 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"); +} diff --git a/lib/use_case/ls.dart b/lib/use_case/ls.dart new file mode 100644 index 00000000..83148379 --- /dev/null +++ b/lib/use_case/ls.dart @@ -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> 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; +} diff --git a/lib/use_case/put_file_binary.dart b/lib/use_case/put_file_binary.dart new file mode 100644 index 00000000..7f7a7b3f --- /dev/null +++ b/lib/use_case/put_file_binary.dart @@ -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 call(Account account, String path, Uint8List content) => + fileRepo.putBinary(account, path, content); + + final FileRepo fileRepo; +} diff --git a/lib/use_case/remove.dart b/lib/use_case/remove.dart new file mode 100644 index 00000000..08fbbced --- /dev/null +++ b/lib/use_case/remove.dart @@ -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 call(Account account, File file) async { + await fileRepo.remove(account, file); + await _cleanUpAlbums(account, file); + KiwiContainer().resolve().fire(FileRemovedEvent(account, file)); + } + + Future _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"); +} diff --git a/lib/use_case/scan_dir.dart b/lib/use_case/scan_dir.dart new file mode 100644 index 00000000..ba2db076 --- /dev/null +++ b/lib/use_case/scan_dir.dart @@ -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 data or an exception + Stream 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 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"); +} diff --git a/lib/use_case/scan_missing_metadata.dart b/lib/use_case/scan_missing_metadata.dart new file mode 100644 index 00000000..03510bf9 --- /dev/null +++ b/lib/use_case/scan_missing_metadata.dart @@ -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 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).where((element) => + file_util.isSupportedFormat(element) && element.metadata == null); + for (final f in missingMetadata) { + yield f; + } + } + } + + final FileRepo fileRepo; +} diff --git a/lib/use_case/update_album.dart b/lib/use_case/update_album.dart new file mode 100644 index 00000000..0d1031bf --- /dev/null +++ b/lib/use_case/update_album.dart @@ -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 call(Account account, Album album) async { + await albumRepo.update(account, album); + KiwiContainer().resolve().fire(AlbumUpdatedEvent(account, album)); + } + + final AlbumRepo albumRepo; +} diff --git a/lib/use_case/update_metadata.dart b/lib/use_case/update_metadata.dart new file mode 100644 index 00000000..ccc1ba14 --- /dev/null +++ b/lib/use_case/update_metadata.dart @@ -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 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() + .fire(FileMetadataUpdatedEvent(account, file)); + } + + Future _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"); +} diff --git a/lib/use_case/update_missing_metadata.dart b/lib/use_case/update_missing_metadata.dart new file mode 100644 index 00000000..134c5433 --- /dev/null +++ b/lib/use_case/update_missing_metadata.dart @@ -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 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"); +} diff --git a/lib/web/downloader.dart b/lib/web/downloader.dart new file mode 100644 index 00000000..c4890295 --- /dev/null +++ b/lib/web/downloader.dart @@ -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), + ]); + } +} diff --git a/lib/web/metadata_loader.dart b/lib/web/metadata_loader.dart new file mode 100644 index 00000000..c7c08151 --- /dev/null +++ b/lib/web/metadata_loader.dart @@ -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"); +} diff --git a/lib/web/my_app.dart b/lib/web/my_app.dart new file mode 100644 index 00000000..de7b8728 --- /dev/null +++ b/lib/web/my_app.dart @@ -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(); +} diff --git a/lib/web/platform.dart b/lib/web/platform.dart new file mode 100644 index 00000000..b2817d62 --- /dev/null +++ b/lib/web/platform.dart @@ -0,0 +1,3 @@ +export 'downloader.dart'; +export 'metadata_loader.dart'; +export 'my_app.dart'; diff --git a/lib/widget/account_picker_dialog.dart b/lib/widget/account_picker_dialog.dart new file mode 100644 index 00000000..5f5c1071 --- /dev/null +++ b/lib/widget/account_picker_dialog.dart @@ -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 { + @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 _accounts; +} diff --git a/lib/widget/album_grid_item.dart b/lib/widget/album_grid_item.dart new file mode 100644 index 00000000..694f1245 --- /dev/null +++ b/lib/widget/album_grid_item.dart @@ -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; +} diff --git a/lib/widget/album_picker_dialog.dart b/lib/widget/album_picker_dialog.dart new file mode 100644 index 00000000..a24eec3a --- /dev/null +++ b/lib/widget/album_picker_dialog.dart @@ -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 { + @override + initState() { + super.initState(); + _initBloc(); + } + + @override + build(BuildContext context) { + return BlocListener( + bloc: _bloc, + listener: (context, state) => _onStateChange(context, state), + child: BlocBuilder( + 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($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(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 albums) { + _items.clear(); + _items.addAll(albums); + } + + void _reqQuery() { + _bloc.add(ListAlbumBlocQuery(widget.account)); + } + + ListAlbumBloc _bloc; + + final _items = []; + + var _isVisible = true; + + static final _log = + Logger("widget.album_picker_dialog._AlbumPickerDialogState"); +} diff --git a/lib/widget/album_viewer.dart b/lib/widget/album_viewer.dart new file mode 100644 index 00000000..fd91e5d1 --- /dev/null +++ b/lib/widget/album_viewer.dart @@ -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 + 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() + .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 = []; + + 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; +} diff --git a/lib/widget/cached_network_image_mod.dart b/lib/widget/cached_network_image_mod.dart new file mode 100644 index 00000000..098f95b8 --- /dev/null +++ b/lib/widget/cached_network_image_mod.dart @@ -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 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); + } +} diff --git a/lib/widget/connect.dart b/lib/widget/connect.dart new file mode 100644 index 00000000..6fbe6280 --- /dev/null +++ b/lib/widget/connect.dart @@ -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 { + @override + initState() { + super.initState(); + _initBloc(); + } + + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + body: + BlocListener( + 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"); +} diff --git a/lib/widget/home.dart b/lib/widget/home.dart new file mode 100644 index 00000000..60ea166d --- /dev/null +++ b/lib/widget/home.dart @@ -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 { + @override + initState() { + super.initState(); + if (Pref.inst().isEnableExif()) { + KiwiContainer() + .resolve() + .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( + 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; +} diff --git a/lib/widget/home_albums.dart b/lib/widget/home_albums.dart new file mode 100644 index 00000000..b5f4309d --- /dev/null +++ b/lib/widget/home_albums.dart @@ -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 { + @override + initState() { + super.initState(); + _initBloc(); + } + + @override + build(BuildContext context) { + return BlocListener( + bloc: _bloc, + listener: (context, state) => _onStateChange(context, state), + child: BlocBuilder( + 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($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(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() + .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 _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 = []; + 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 albums) { + final sortedAlbums = albums.map((e) { + // find the latest file in this album + try { + return Tuple2( + e.items + .whereType() + .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; +} diff --git a/lib/widget/home_app_bar.dart b/lib/widget/home_app_bar.dart new file mode 100644 index 00000000..e0b05293 --- /dev/null +++ b/lib/widget/home_app_bar.dart @@ -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 actions; + + /// Screen specific actions under the overflow menu. The value of each item + /// much >= 0 + final List> menuActions; + final void Function(int) onSelectedMenuActions; + + static const _menuValueAbout = -1; +} diff --git a/lib/widget/home_photos.dart b/lib/widget/home_photos.dart new file mode 100644 index 00000000..c1d7dd0f --- /dev/null +++ b/lib/widget/home_photos.dart @@ -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 { + @override + initState() { + super.initState(); + _initBloc(); + _thumbZoomLevel = Pref.inst().getHomePhotosZoomLevel(0); + } + + @override + build(BuildContext context) { + return BlocListener( + bloc: _bloc, + listener: (context, state) => _onStateChange(context, state), + child: BlocBuilder( + 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($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(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 _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 _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 = []; + 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 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 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 = []; + + 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()); + } +} diff --git a/lib/widget/image_grid_item.dart b/lib/widget/image_grid_item.dart new file mode 100644 index 00000000..04c24d53 --- /dev/null +++ b/lib/widget/image_grid_item.dart @@ -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; +} diff --git a/lib/widget/my_app.dart b/lib/widget/my_app.dart new file mode 100644 index 00000000..8fbe7f3e --- /dev/null +++ b/lib/widget/my_app.dart @@ -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 _getRouter() => { + Setup.routeName: (context) => Setup(), + SignIn.routeName: (context) => SignIn(), + Splash.routeName: (context) => Splash(), + }; + + Route _onGenerateRoute(RouteSettings settings) { + _log.info("[_onGenerateRoute] Route: ${settings.name}"); + Route 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 _handleBasicRoute(RouteSettings settings) { + for (final e in _getRouter().entries) { + if (e.key == settings.name) { + return MaterialPageRoute( + builder: e.value, + ); + } + } + return null; + } + + Route _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 _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 _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 _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 _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 _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(); + + static final _log = Logger("widget.my_app.MyApp"); +} diff --git a/lib/widget/new_album_dialog.dart b/lib/widget/new_album_dialog.dart new file mode 100644 index 00000000..831d0352 --- /dev/null +++ b/lib/widget/new_album_dialog.dart @@ -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 { + @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(); + + final _formValue = _FormValue(); + + static final _log = Logger("widget.new_album_dialog._AlbumPickerDialogState"); +} + +class _FormValue { + String name; +} diff --git a/lib/widget/popup_menu_zoom.dart b/lib/widget/popup_menu_zoom.dart new file mode 100644 index 00000000..202f42d0 --- /dev/null +++ b/lib/widget/popup_menu_zoom.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +class PopupMenuZoom extends PopupMenuEntry { + 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 { + @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; +} diff --git a/lib/widget/root_picker.dart b/lib/widget/root_picker.dart new file mode 100644 index 00000000..f1d4d69c --- /dev/null +++ b/lib/widget/root_picker.dart @@ -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 { + @override + initState() { + super.initState(); + _initBloc(); + } + + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + body: BlocListener( + bloc: _bloc, + listener: (context, state) => _onStateChange(context, state), + child: BlocBuilder( + 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: [ + ...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: [ + 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 _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 = []; + 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 _pickedAllExclude( + LsDirBlocItem item, LsDirBlocItem exclude) { + if (item == exclude) { + return []; + } + _log.fine( + "[_pickedAllExclude] Unpicking '${item.file.path}' and picking children"); + final products = []; + 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 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 _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 = []; + var _picks = []; + + static final _log = Logger("widget.root_picker._RootPickerState"); +} + +enum PickState { + notPicked, + picked, + childPicked, +} diff --git a/lib/widget/settings.dart b/lib/widget/settings.dart new file mode 100644 index 00000000..d1a5d5f3 --- /dev/null +++ b/lib/widget/settings.dart @@ -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 { + @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: [ + 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() + .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"); +} diff --git a/lib/widget/setup.dart b/lib/widget/setup.dart new file mode 100644 index 00000000..3a759563 --- /dev/null +++ b/lib/widget/setup.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/pref.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/widget/home.dart'; +import 'package:nc_photos/widget/sign_in.dart'; +import 'package:page_view_indicators/circle_page_indicator.dart'; + +bool isNeedSetup() => Pref.inst().getSetupProgress() != _PageId.all; + +class Setup extends StatefulWidget { + static const routeName = "/setup"; + + @override + createState() => _SetupState(); +} + +class _SetupState extends State { + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + appBar: _buildAppBar(context), + body: Builder(builder: (context) => _buildContent(context)), + ), + ); + } + + Widget _buildAppBar(BuildContext context) { + return AppBar( + title: Text(AppLocalizations.of(context).setupWidgetTitle), + elevation: 0, + ); + } + + Widget _buildContent(BuildContext context) { + final page = _pageController.hasClients ? _pageController.page.round() : 0; + final pages = [ + if (_initialProgress & _PageId.exif == 0) _Exif(), + if (_initialProgress & _PageId.hiddenPrefDirNotice == 0) + _HiddenPrefDirNotice(), + ]; + final isLastPage = page >= pages.length - 1; + return Column( + children: [ + Expanded( + child: PageView( + controller: _pageController, + physics: NeverScrollableScrollPhysics(), + children: pages, + onPageChanged: (page) { + setState(() { + _currentPageNotifier.value = page; + }); + }, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + child: Stack( + alignment: Alignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: isLastPage + ? [ + ElevatedButton( + onPressed: _onDonePressed, + child: Text( + AppLocalizations.of(context).doneButtonLabel), + ), + ] + : [ + ElevatedButton( + onPressed: () => _onNextPressed( + (pages[_pageController.page.round()] as _Page) + .getPageId()), + child: Text( + AppLocalizations.of(context).nextButtonLabel), + ), + ], + ), + CirclePageIndicator( + itemCount: pages.length, + currentPageNotifier: _currentPageNotifier, + ), + ], + ), + ), + ], + ); + } + + void _onDonePressed() { + Pref.inst().setSetupProgress(_PageId.all); + + final account = Pref.inst().getCurrentAccount(); + if (account == null) { + Navigator.pushReplacementNamed(context, SignIn.routeName); + } else { + Navigator.pushReplacementNamed(context, Home.routeName, + arguments: HomeArguments(account)); + } + } + + void _onNextPressed(int pageId) { + Pref.inst().setSetupProgress(Pref.inst().getSetupProgress() | pageId); + _pageController.nextPage( + duration: k.animationDurationNormal, curve: Curves.easeInOut); + } + + final _initialProgress = Pref.inst().getSetupProgress(); + final _pageController = PageController(); + var _currentPageNotifier = ValueNotifier(0); +} + +class _PageId { + static const exif = 0x01; + static const hiddenPrefDirNotice = 0x02; + static const all = exif | hiddenPrefDirNotice; +} + +abstract class _Page { + int getPageId(); +} + +class _Exif extends StatefulWidget implements _Page { + @override + createState() => _ExifState(); + + @override + getPageId() => _PageId.exif; +} + +class _ExifState extends State<_Exif> { + @override + build(BuildContext context) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + title: Text(AppLocalizations.of(context).settingsExifSupportTitle), + value: _isEnableExif, + onChanged: _onValueChanged, + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(AppLocalizations.of(context).exifSupportDetails), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + AppLocalizations.of(context).setupSettingsModifyLaterHint, + style: Theme.of(context) + .textTheme + .bodyText2 + .copyWith(fontStyle: FontStyle.italic)), + ), + const SizedBox(height: 8), + ], + ), + ); + } + + void _onValueChanged(bool value) { + Pref.inst().setEnableExif(value); + setState(() { + _isEnableExif = value; + }); + } + + bool _isEnableExif = Pref.inst().isEnableExif(); +} + +class _HiddenPrefDirNotice extends StatefulWidget implements _Page { + @override + createState() => _HiddenPrefDirNoticeState(); + + @override + getPageId() => _PageId.hiddenPrefDirNotice; +} + +class _HiddenPrefDirNoticeState extends State<_HiddenPrefDirNotice> { + @override + build(BuildContext context) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + AppLocalizations.of(context).setupHiddenPrefDirNoticeDetail), + ), + const SizedBox(height: 24), + Align( + alignment: Alignment.center, + child: Image.asset( + "assets/setup_hidden_pref_dir.png", + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ), + const SizedBox(height: 8), + ], + ), + ); + } +} diff --git a/lib/widget/sign_in.dart b/lib/widget/sign_in.dart new file mode 100644 index 00000000..fa21481d --- /dev/null +++ b/lib/widget/sign_in.dart @@ -0,0 +1,260 @@ +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/list_extension.dart'; +import 'package:nc_photos/pref.dart'; +import 'package:nc_photos/string_extension.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/widget/connect.dart'; +import 'package:nc_photos/widget/home.dart'; +import 'package:nc_photos/widget/root_picker.dart'; + +class SignIn extends StatefulWidget { + static const routeName = "/sign-in"; + + SignIn({Key key}) : super(key: key); + + @override + createState() => _SignInState(); +} + +class _SignInState extends State { + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + body: Builder(builder: (context) => _buildContent(context)), + ), + ); + } + + Widget _buildContent(BuildContext context) { + return SafeArea( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints viewportConstraints) { + return Form( + key: _formKey, + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: Text( + AppLocalizations.of(context).signInHeaderText, + style: Theme.of(context).textTheme.headline5, + textAlign: TextAlign.center, + ), + ), + Align( + alignment: Alignment.center, + child: Container( + constraints: const BoxConstraints( + maxWidth: AppTheme.widthLimitedContentMaxWidth), + padding: const EdgeInsets.symmetric(horizontal: 32), + child: _buildForm(context), + ), + ), + Expanded(child: Container()), + 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: () { + Navigator.pop(context); + }, + child: Text(MaterialLocalizations.of(context) + .cancelButtonLabel), + ) + else + Container(), + ElevatedButton( + onPressed: () { + if (_formKey.currentState.validate()) { + _connect(); + } + }, + child: Text(AppLocalizations.of(context) + .connectButtonLabel), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildForm(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Icon( + Icons.cloud, + color: Theme.of(context).primaryColor, + size: 72, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Container( + width: 64, + child: DropdownButtonHideUnderline( + child: DropdownButtonFormField<_Scheme>( + value: _scheme, + items: [_Scheme.http, _Scheme.https] + .map((e) => DropdownMenuItem<_Scheme>( + value: e, + child: Text(e.toValueString()), + )) + .toList(), + onChanged: (newValue) { + setState(() { + _scheme = newValue; + }); + }, + onSaved: (value) { + _formValue.scheme = value.toValueString(); + }, + ), + ), + ), + const Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: const Text("://"), + ), + Expanded( + child: TextFormField( + decoration: InputDecoration( + hintText: AppLocalizations.of(context).serverAddressInputHint, + ), + keyboardType: TextInputType.url, + validator: (value) { + if (value.trim().trimRightAny("/").isEmpty) { + return AppLocalizations.of(context) + .serverAddressInputInvalidEmpty; + } + return null; + }, + onSaved: (value) { + _formValue.address = value.trim().trimRightAny("/"); + }, + ), + ), + ], + ), + const SizedBox(height: 8), + TextFormField( + decoration: InputDecoration( + hintText: AppLocalizations.of(context).usernameInputHint, + ), + validator: (value) { + if (value.trim().isEmpty) { + return AppLocalizations.of(context).usernameInputInvalidEmpty; + } + return null; + }, + onSaved: (value) { + _formValue.username = value; + }, + ), + const SizedBox(height: 8), + TextFormField( + decoration: InputDecoration( + hintText: AppLocalizations.of(context).passwordInputHint, + ), + obscureText: true, + validator: (value) { + if (value.trim().isEmpty) { + return AppLocalizations.of(context).passwordInputInvalidEmpty; + } + return null; + }, + onSaved: (value) { + _formValue.password = value; + }, + ), + ], + ); + } + + void _connect() { + _formKey.currentState.save(); + final account = Account(_formValue.scheme, _formValue.address, + _formValue.username, _formValue.password, [""]); + _log.info("[_connect] Try connecting with account: $account"); + Navigator.pushNamed(context, Connect.routeName, + arguments: ConnectArguments(account)) + .then((result) { + return result != null + ? Navigator.pushNamed(context, RootPicker.routeName, + arguments: RootPickerArguments(result)) + : null; + }).then((result) { + if (result != null) { + // we've got a good account + final accounts = (Pref.inst().getAccounts([])..add(result)).distinct(); + Pref.inst() + ..setAccounts(accounts) + ..setCurrentAccountIndex(accounts.indexOf(result)); + Navigator.pushNamedAndRemoveUntil( + context, Home.routeName, (route) => false, + arguments: HomeArguments(result)); + } + }); + } + + final _formKey = GlobalKey(); + var _scheme = _Scheme.https; + + final _formValue = _FormValue(); + + static final _log = Logger("widget.sign_in._SignInState"); +} + +enum _Scheme { + http, + https, +} + +extension on _Scheme { + String toValueString() { + switch (this) { + case _Scheme.http: + return "http"; + + case _Scheme.https: + return "https"; + + default: + throw StateError("Unknown value: $this"); + } + } +} + +class _FormValue { + String scheme; + String address; + String username; + String password; +} diff --git a/lib/widget/splash.dart b/lib/widget/splash.dart new file mode 100644 index 00000000..d1df6e04 --- /dev/null +++ b/lib/widget/splash.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:nc_photos/pref.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/widget/home.dart'; +import 'package:nc_photos/widget/setup.dart'; +import 'package:nc_photos/widget/sign_in.dart'; + +/// A useless widget +class Splash extends StatefulWidget { + static const routeName = "/splash"; + + Splash({Key key}) : super(key: key); + + @override + createState() => _SplashState(); +} + +class _SplashState extends State { + @override + initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + Future.delayed(const Duration(seconds: 1)).then((_) { + final account = Pref.inst().getCurrentAccount(); + if (isNeedSetup()) { + Navigator.pushReplacementNamed(context, Setup.routeName); + } else if (account == null) { + Navigator.pushReplacementNamed(context, SignIn.routeName); + } else { + Navigator.pushReplacementNamed(context, Home.routeName, + arguments: HomeArguments(account)); + } + }); + }); + } + + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + body: Builder(builder: (context) => _buildContent(context)), + ), + ); + } + + Widget _buildContent(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud, + size: 96, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 8), + Text( + AppLocalizations.of(context).appTitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headline4, + ) + ], + ), + ), + ); + } +} diff --git a/lib/widget/viewer.dart b/lib/widget/viewer.dart new file mode 100644 index 00000000..57abb8b5 --- /dev/null +++ b/lib/widget/viewer.dart @@ -0,0 +1,722 @@ +import 'dart:math'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.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.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/exception.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/mobile/platform.dart' + if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform; +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/cached_network_image_mod.dart' as mod; +import 'package:nc_photos/widget/viewer_detail_pane.dart'; + +class ViewerArguments { + ViewerArguments(this.account, this.streamFiles, this.startIndex); + + final Account account; + final List streamFiles; + final int startIndex; +} + +class Viewer extends StatefulWidget { + static const routeName = "/viewer"; + + Viewer({ + Key key, + @required this.account, + @required this.streamFiles, + @required this.startIndex, + }) : super(key: key); + + Viewer.fromArgs(ViewerArguments args, {Key key}) + : this( + key: key, + account: args.account, + streamFiles: args.streamFiles, + startIndex: args.startIndex, + ); + + @override + createState() => _ViewerState(); + + final Account account; + final List streamFiles; + final int startIndex; +} + +class _ViewerState extends State with TickerProviderStateMixin { + @override + void initState() { + super.initState(); + _pageController = PageController( + initialPage: widget.startIndex, + viewportFraction: 1.05, + keepPage: false); + _pageFocus.requestFocus(); + } + + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + body: Builder(builder: (context) => _buildContent(context)), + ), + ); + } + + Widget _buildContent(BuildContext context) { + Widget content = Listener( + onPointerDown: (event) { + ++_finger; + if (_finger >= 2 && _canZoom()) { + _setIsZooming(true); + } + }, + onPointerUp: (event) { + --_finger; + if (_finger < 2) { + _setIsZooming(false); + } + _prevFingerPosition = event.position; + }, + child: GestureDetector( + onTap: () { + setState(() { + _setShowActionBar(!_isShowAppBar); + }); + }, + onDoubleTap: () { + if (_canZoom()) { + if (_isZoomed()) { + // restore transformation + _autoZoomOut(); + } else { + _autoZoomIn(); + } + } + }, + child: Stack( + children: [ + Container(color: Colors.black), + if (!_pageController.hasClients || + !_pageStates[_pageController.page.round()].hasPreloaded) + Align( + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ), + PageView.builder( + controller: _pageController, + itemCount: widget.streamFiles.length, + itemBuilder: _buildPage, + physics: _canSwitchPage() + ? null + : const NeverScrollableScrollPhysics(), + ), + _buildBottomAppBar(context), + _buildAppBar(context), + ], + ), + ), + ); + + // support switching pages with keyboard on web + if (kIsWeb) { + content = RawKeyboardListener( + onKey: (ev) { + if (!_canSwitchPage()) { + return; + } + toPrevPage() => _pageController.previousPage( + duration: k.animationDurationNormal, curve: Curves.easeInOut); + toNextPage() => _pageController.nextPage( + duration: k.animationDurationNormal, curve: Curves.easeInOut); + if (ev.isKeyPressed(LogicalKeyboardKey.arrowLeft)) { + if (Directionality.of(context) == TextDirection.ltr) { + toPrevPage(); + } else { + toNextPage(); + } + } else if (ev.isKeyPressed(LogicalKeyboardKey.arrowRight)) { + if (Directionality.of(context) == TextDirection.ltr) { + toNextPage(); + } else { + toPrevPage(); + } + } + }, + focusNode: _pageFocus, + child: content, + ); + } + + return content; + } + + Widget _buildAppBar(BuildContext context) { + return Wrap( + children: [ + AnimatedOpacity( + opacity: _isShowAppBar ? 1.0 : 0.0, + duration: k.animationDurationNormal, + onEnd: () { + if (!_isShowAppBar) { + setState(() { + _isAppBarActive = false; + }); + } + }, + child: Visibility( + visible: _isAppBarActive, + child: Stack( + children: [ + Container( + // + status bar height + height: kToolbarHeight + MediaQuery.of(context).padding.top, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: const Alignment(0, -1), + end: const Alignment(0, 1), + colors: [ + Color.fromARGB(192, 0, 0, 0), + Color.fromARGB(0, 0, 0, 0), + ], + ), + ), + ), + AppBar( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + brightness: Brightness.dark, + iconTheme: + Theme.of(context).iconTheme.copyWith(color: Colors.white), + actionsIconTheme: + Theme.of(context).iconTheme.copyWith(color: Colors.white), + actions: [ + if (!_isDetailPaneActive && _canOpenDetailPane()) + IconButton( + icon: const Icon(Icons.more_vert), + tooltip: AppLocalizations.of(context).detailsTooltip, + onPressed: _onDetailsPressed, + ), + ], + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildBottomAppBar(BuildContext context) { + return Align( + alignment: Alignment.bottomCenter, + child: Material( + type: MaterialType.transparency, + child: AnimatedOpacity( + opacity: _isShowAppBar ? 1.0 : 0.0, + duration: k.animationDurationNormal, + child: Visibility( + visible: _isAppBarActive && !_isDetailPaneActive, + child: Container( + height: kToolbarHeight, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: const Alignment(0, -1), + end: const Alignment(0, 1), + colors: [ + Color.fromARGB(0, 0, 0, 0), + Color.fromARGB(192, 0, 0, 0), + ], + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + flex: 1, + child: IconButton( + icon: const Icon(Icons.download_outlined, + color: Colors.white), + tooltip: AppLocalizations.of(context).downloadTooltip, + onPressed: () => _onDownloadPressed(context), + ), + ), + Expanded( + flex: 1, + child: IconButton( + icon: const Icon(Icons.delete_outlined, + color: Colors.white), + tooltip: AppLocalizations.of(context).deleteTooltip, + onPressed: () => _onDeletePressed(context), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildPage(BuildContext context, int index) { + if (_pageStates[index] == null) { + _onCreateNewPage(context, index); + } else if (!_pageStates[index].scrollController.hasClients) { + // the page has been moved out of view and is now coming back + _log.fine("[_buildPage] Recreating page#$index"); + _onRecreatePageAfterMovedOut(context, index); + } + + if (kDebugMode) { + _log.info("[_buildPage] $index"); + } + + return FractionallySizedBox( + widthFactor: 1 / _pageController.viewportFraction, + child: NotificationListener( + onNotification: (notif) => _onPageContentScrolled(notif, index), + child: SingleChildScrollView( + controller: _pageStates[index].scrollController, + physics: + _isDetailPaneActive ? null : const NeverScrollableScrollPhysics(), + child: Stack( + children: [ + _buildItemView(context, index), + Visibility( + visible: _isDetailPaneActive, + child: AnimatedOpacity( + opacity: _isShowDetailPane ? 1 : 0, + duration: k.animationDurationNormal, + onEnd: () { + if (!_isShowDetailPane) { + setState(() { + _isDetailPaneActive = false; + }); + } + }, + child: Container( + alignment: Alignment.topLeft, + constraints: BoxConstraints( + minHeight: MediaQuery.of(context).size.height), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.vertical( + top: const Radius.circular(4)), + ), + margin: EdgeInsets.only(top: _calcDetailPaneOffset(index)), + child: ViewerDetailPane( + account: widget.account, + file: widget.streamFiles[index], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildItemView(BuildContext context, int index) { + return InteractiveViewer( + minScale: 1.0, + maxScale: 3.0, + transformationController: _transformationController, + panEnabled: _canZoom(), + scaleEnabled: _canZoom(), + // allow the image to be zoomed to fill the whole screen + child: Container( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + alignment: Alignment.center, + child: NotificationListener( + onNotification: (_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_pageStates[index].key.currentContext != null) { + _updateItemHeight( + index, _pageStates[index].key.currentContext.size.height); + } + }); + return false; + }, + child: SizeChangedLayoutNotifier( + child: mod.CachedNetworkImage( + key: _pageStates[index].key, + imageUrl: _getImageUrl(widget.account, widget.streamFiles[index]), + httpHeaders: { + "Authorization": + Api.getAuthorizationHeaderValue(widget.account), + }, + fit: BoxFit.contain, + fadeInDuration: const Duration(), + filterQuality: FilterQuality.high, + imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, + imageBuilder: (context, child, imageProvider) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _onItemLoaded(index); + }); + SizeChangedLayoutNotification().dispatch(context); + return child; + }, + ), + ), + ), + ), + ); + } + + bool _onPageContentScrolled(ScrollNotification notification, int index) { + if (!_canOpenDetailPane()) { + return false; + } + if (notification is ScrollEndNotification) { + final scrollPos = _pageStates[index].scrollController.position; + if (scrollPos.pixels == 0) { + setState(() { + _onDetailPaneClosed(); + }); + } else if (scrollPos.pixels < + _calcDetailPaneOpenedScrollPosition(index)) { + if (scrollPos.userScrollDirection == ScrollDirection.reverse) { + // upward, open the pane to its minimal size + Future.delayed(Duration.zero, () { + setState(() { + _openDetailPane(_pageController.page.toInt(), + shouldAnimate: true); + }); + }); + } else { + // downward, close the pane + Future.delayed(Duration.zero, () { + _closeDetailPane(_pageController.page.toInt(), shouldAnimate: true); + }); + } + } + } + return false; + } + + void _onItemLoaded(int index) { + // currently pageview doesn't pre-load pages, we do it manually + // don't pre-load if user already navigated away + if (_pageController.page.round() == index && + !_pageStates[index].hasPreloaded) { + _log.info("[_onItemLoaded] Pre-loading nearby items"); + if (index > 0) { + DefaultCacheManager().getFileStream( + _getImageUrl(widget.account, widget.streamFiles[index - 1]), + headers: { + "Authorization": Api.getAuthorizationHeaderValue(widget.account), + }, + ); + } + if (index + 1 < widget.streamFiles.length) { + DefaultCacheManager().getFileStream( + _getImageUrl(widget.account, widget.streamFiles[index + 1]), + headers: { + "Authorization": Api.getAuthorizationHeaderValue(widget.account), + }, + ); + } + setState(() { + _pageStates[index].hasPreloaded = true; + }); + } + } + + /// Called when the page is being built for the first time + void _onCreateNewPage(BuildContext context, int index) { + _pageStates[index] = _PageState(ScrollController( + initialScrollOffset: _isShowDetailPane && !_isClosingDetailPane + ? _calcDetailPaneOpenedScrollPosition(index) + : 0)); + } + + /// Called when the page is being built after previously moved out of view + void _onRecreatePageAfterMovedOut(BuildContext context, int index) { + if (_isShowDetailPane && !_isClosingDetailPane) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_pageStates[index].itemHeight != null) { + setState(() { + _openDetailPane(index); + }); + } + }); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + _pageStates[index].scrollController.jumpTo(0); + }); + } + } + + void _onDetailsPressed() { + if (!_isDetailPaneActive) { + setState(() { + _openDetailPane(_pageController.page.toInt(), shouldAnimate: true); + }); + } + } + + void _onDownloadPressed(BuildContext context) async { + final file = widget.streamFiles[_pageController.page.round()]; + _log.info("[_onDownloadPressed] Downloading file: ${file.path}"); + var controller = SnackBarManager().showSnackBar(SnackBar( + content: + Text(AppLocalizations.of(context).downloadProcessingNotification), + duration: k.snackBarDurationShort, + )); + controller?.closed?.whenComplete(() { + controller = null; + }); + try { + await platform.Downloader().downloadFile(widget.account, file); + controller?.close(); + SnackBarManager().showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context).downloadSuccessNotification), + duration: k.snackBarDurationShort, + )); + } on PermissionException catch (_) { + _log.warning("[_onDownloadPressed] Permission not granted"); + controller?.close(); + SnackBarManager().showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context) + .downloadFailureNoPermissionNotification), + duration: k.snackBarDurationNormal, + )); + } catch (e, stacktrace) { + _log.severe( + "[_onDownloadPressed] Failed while downloadFile", e, stacktrace); + controller?.close(); + SnackBarManager().showSnackBar(SnackBar( + content: + Text("${AppLocalizations.of(context).downloadFailureNotification}: " + "${exception_util.toUserString(e, context)}"), + duration: k.snackBarDurationNormal, + )); + } + } + + void _onDeletePressed(BuildContext context) async { + final file = widget.streamFiles[_pageController.page.round()]; + _log.info("[_onDeletePressed] Removing file: ${file.path}"); + var controller = SnackBarManager().showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context).deleteProcessingNotification), + duration: k.snackBarDurationShort, + )); + controller?.closed?.whenComplete(() { + controller = null; + }); + try { + await Remove(FileRepo(FileCachedDataSource()), + AlbumRepo(AlbumCachedDataSource()))(widget.account, file); + controller?.close(); + SnackBarManager().showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context).deleteSuccessNotification), + duration: k.snackBarDurationNormal, + )); + Navigator.of(context).pop(); + } catch (e, stacktrace) { + _log.severe("[_onDeletePressed] Failed while remove: ${file.path}", e, + stacktrace); + controller?.close(); + SnackBarManager().showSnackBar(SnackBar( + content: + Text("${AppLocalizations.of(context).deleteFailureNotification}: " + "${exception_util.toUserString(e, context)}"), + duration: k.snackBarDurationNormal, + )); + } + } + + double _calcDetailPaneOffset(int index) { + if (_pageStates[index]?.itemHeight == null) { + return MediaQuery.of(context).size.height; + } else { + return _pageStates[index].itemHeight + + (MediaQuery.of(context).size.height - _pageStates[index].itemHeight) / + 2 - + 4; + } + } + + double _calcDetailPaneOpenedScrollPosition(int index) { + // distance of the detail pane from the top edge + const distanceFromTop = 196; + return max(_calcDetailPaneOffset(index) - distanceFromTop, 0); + } + + void _updateItemHeight(int index, double height) { + if (_pageStates[index].itemHeight != height) { + _log.fine("[_updateItemHeight] New height of item#$index: $height"); + setState(() { + _pageStates[index].itemHeight = height; + if (_isDetailPaneActive) { + _openDetailPane(index); + } + }); + } + } + + void _setShowActionBar(bool flag) { + _isShowAppBar = flag; + if (flag) { + _isAppBarActive = true; + } + } + + void _openDetailPane(int index, {bool shouldAnimate = false}) { + if (!_canOpenDetailPane()) { + _log.warning("[_openDetailPane] Can't open detail pane right now"); + return; + } + + _isShowDetailPane = true; + _isDetailPaneActive = true; + if (shouldAnimate) { + _pageStates[index].scrollController.animateTo( + _calcDetailPaneOpenedScrollPosition(index), + duration: k.animationDurationNormal, + curve: Curves.easeOut); + } else { + _pageStates[index] + .scrollController + .jumpTo(_calcDetailPaneOpenedScrollPosition(index)); + } + } + + void _closeDetailPane(int index, {bool shouldAnimate = false}) { + _isClosingDetailPane = true; + if (shouldAnimate) { + _pageStates[index].scrollController.animateTo(0, + duration: k.animationDurationNormal, curve: Curves.easeOut); + } + } + + void _onDetailPaneClosed() { + _isShowDetailPane = false; + _isClosingDetailPane = false; + } + + void _setIsZooming(bool flag) { + _isZooming = flag; + final next = _isZoomed(); + if (next != _wasZoomed) { + _wasZoomed = next; + setState(() { + _log.info("[_setIsZooming] Is zoomed: $next"); + }); + } + } + + bool _isZoomed() { + return _isZooming || + _transformationController.value.getMaxScaleOnAxis() != 1.0; + } + + /// Called when double tapping the image to zoom in to the default level + void _autoZoomIn() { + final animController = + AnimationController(duration: k.animationDurationShort, vsync: this); + final originX = -_prevFingerPosition.dx / 2; + final originY = -_prevFingerPosition.dy / 2; + final anim = Matrix4Tween( + begin: Matrix4.identity(), + end: Matrix4.identity() + ..scale(2.0) + ..translate(originX, originY)) + .animate(animController); + animController + ..addListener(() { + _transformationController.value = anim.value; + }) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + _setIsZooming(false); + } + }) + ..forward(); + _setIsZooming(true); + } + + /// Called when double tapping the zoomed image to zoom out + void _autoZoomOut() { + final animController = + AnimationController(duration: k.animationDurationShort, vsync: this); + final anim = Matrix4Tween( + begin: _transformationController.value, end: Matrix4.identity()) + .animate(animController); + animController + ..addListener(() { + _transformationController.value = anim.value; + }) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + _setIsZooming(false); + } + }) + ..forward(); + _setIsZooming(true); + } + + bool _canSwitchPage() => !_isZoomed(); + bool _canOpenDetailPane() => !_isZoomed(); + bool _canZoom() => !_isDetailPaneActive; + + String _getImageUrl(Account account, File file) => api_util.getFilePreviewUrl( + account, + file, + width: 1080, + height: 1080, + a: true, + ); + + var _isShowAppBar = true; + var _isAppBarActive = true; + + var _isShowDetailPane = false; + var _isDetailPaneActive = false; + var _isClosingDetailPane = false; + + var _isZooming = false; + var _wasZoomed = false; + final _transformationController = TransformationController(); + + int _finger = 0; + Offset _prevFingerPosition; + + PageController _pageController; + final _pageStates = {}; + + /// used to gain focus on web for keyboard support + final _pageFocus = FocusNode(); + + static final _log = Logger("widget.viewer._ViewerState"); +} + +class _PageState { + _PageState(this.scrollController); + + ScrollController scrollController; + double itemHeight; + bool hasPreloaded = false; + GlobalKey key = GlobalKey(); +} diff --git a/lib/widget/viewer_detail_pane.dart b/lib/widget/viewer_detail_pane.dart new file mode 100644 index 00000000..8166058b --- /dev/null +++ b/lib/widget/viewer_detail_pane.dart @@ -0,0 +1,345 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:intl/intl.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/double_extension.dart'; +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/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: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:path/path.dart'; + +class ViewerDetailPane extends StatefulWidget { + const ViewerDetailPane({ + Key key, + @required this.account, + @required this.file, + }) : super(key: key); + + @override + createState() => _ViewerDetailPaneState(); + + final Account account; + final File file; +} + +class _ViewerDetailPaneState extends State { + @override + initState() { + super.initState(); + + if (widget.file.metadata == null) { + _log.info("[initState] Metadata missing in File"); + } else { + _log.info("[initState] Metadata exists in File"); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _updateMetadata(widget.file.metadata.imageWidth, + widget.file.metadata.imageHeight, widget.file.metadata.exif); + }); + } + } + + @override + build(BuildContext context) { + final dateTime = (_dateTime ?? widget.file.lastModified).toLocal(); + final dateStr = DateFormat(DateFormat.YEAR_ABBR_MONTH_DAY).format(dateTime); + final timeStr = DateFormat(DateFormat.HOUR_MINUTE).format(dateTime); + + String sizeSubStr = ""; + const space = " "; + if (_width != null && _height != null) { + final pixelCount = _width * _height; + if (pixelCount >= 500000) { + final mpCount = pixelCount / 1000000.0; + sizeSubStr += AppLocalizations.of(context) + .megapixelCount(mpCount.toStringAsFixed(1)); + sizeSubStr += space; + } + sizeSubStr += _byteSizeToString(widget.file.contentLength); + } + + String cameraSubStr = ""; + if (_fNumber != null) { + cameraSubStr += "f/${_fNumber.toStringAsFixed(1)}$space"; + } + if (_exposureTime != null) { + cameraSubStr += + AppLocalizations.of(context).secondCountSymbol(_exposureTime); + cameraSubStr += space; + } + if (_focalLength != null) { + cameraSubStr += AppLocalizations.of(context) + .millimeterCountSymbol(_focalLength.toStringAsFixedTruncated(2)); + cameraSubStr += space; + } + if (_isoSpeedRatings != null) { + cameraSubStr += "ISO$_isoSpeedRatings$space"; + } + cameraSubStr = cameraSubStr.trim(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _DetailPaneButton( + icon: Icons.playlist_add_outlined, + label: AppLocalizations.of(context).addToAlbumTooltip, + onPressed: () => _onAddToAlbumPressed(context), + ), + _DetailPaneButton( + icon: Icons.delete_outline, + label: AppLocalizations.of(context).deleteTooltip, + onPressed: () => _onDeletePressed(context), + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: const Divider(), + ), + ListTile( + leading: const Icon(Icons.image_outlined), + title: Text(basenameWithoutExtension(widget.file.path)), + subtitle: Text(widget.file.strippedPath), + ), + ListTile( + leading: const Icon(Icons.calendar_today_outlined), + title: Text("$dateStr $timeStr"), + ), + if (_width != null && _height != null) + ListTile( + leading: const Icon(Icons.aspect_ratio), + title: Text("$_width x $_height"), + subtitle: Text(sizeSubStr), + ) + else + ListTile( + leading: const Icon(Icons.aspect_ratio), + title: Text(_byteSizeToString(widget.file.contentLength)), + ), + if (_model != null) + ListTile( + leading: const Icon(Icons.camera_outlined), + title: Text(_model), + subtitle: cameraSubStr.isNotEmpty ? Text(cameraSubStr) : null, + ), + ], + ); + } + + void _onAddToAlbumPressed(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("[_onAddToAlbumPressed] Album picked: $value"); + _addToAlbum(context, value).then((_) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context) + .addToAlbumSuccessNotification(value.name)), + duration: k.snackBarDurationNormal, + )); + }).catchError((_) {}); + } else { + SnackBarManager().showSnackBar(SnackBar( + content: + Text(AppLocalizations.of(context).addToAlbumFailureNotification), + duration: k.snackBarDurationNormal, + )); + } + }).catchError((e, stacktrace) { + _log.severe( + "[_onAddToAlbumPressed] Failed while showDialog", e, stacktrace); + SnackBarManager().showSnackBar(SnackBar( + content: Text( + "${AppLocalizations.of(context).addToAlbumFailureNotification}: " + "${exception_util.toUserString(e, context)}"), + duration: k.snackBarDurationNormal, + )); + }); + } + + void _onDeletePressed(BuildContext context) async { + _log.info("[_onDeletePressed] Removing file: ${widget.file.path}"); + var controller = SnackBarManager().showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context).deleteProcessingNotification), + duration: k.snackBarDurationShort, + )); + controller?.closed?.whenComplete(() { + controller = null; + }); + try { + await Remove(FileRepo(FileCachedDataSource()), + AlbumRepo(AlbumCachedDataSource()))(widget.account, widget.file); + controller?.close(); + SnackBarManager().showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context).deleteSuccessNotification), + duration: k.snackBarDurationNormal, + )); + Navigator.of(context).pop(); + } catch (e, stacktrace) { + _log.severe("[_onDeletePressed] Failed while remove: ${widget.file.path}", + e, stacktrace); + controller?.close(); + SnackBarManager().showSnackBar(SnackBar( + content: + Text("${AppLocalizations.of(context).deleteFailureNotification}: " + "${exception_util.toUserString(e, context)}"), + duration: k.snackBarDurationNormal, + )); + } + } + + void _updateMetadata(int imageWidth, int imageHeight, Exif exif) { + if (imageWidth != null && imageHeight != null) { + setState(() { + _width = imageWidth; + _height = imageHeight; + }); + } + if (exif != null) { + _updateMetadataExif(exif); + } + } + + void _updateMetadataExif(Exif exif) { + _log.info("[_updateMetadataExif] $exif"); + if (exif.dateTimeOriginal != null) { + setState(() { + _dateTime = exif.dateTimeOriginal; + }); + } + if (exif.make != null && exif.model != null) { + setState(() { + _model = "${exif.make} ${exif.model}"; + }); + } + if (exif.fNumber != null) { + setState(() { + _fNumber = exif.fNumber.toDouble(); + }); + } + if (exif.exposureTime != null) { + setState(() { + if (exif.exposureTime.denominator == 1) { + _exposureTime = exif.exposureTime.numerator.toString(); + } else { + _exposureTime = exif.exposureTime.toString(); + } + }); + } + if (exif.focalLength != null) { + setState(() { + _focalLength = exif.focalLength.toDouble(); + }); + } + if (exif.isoSpeedRatings != null) { + setState(() { + _isoSpeedRatings = exif.isoSpeedRatings; + }); + } + } + + Future _addToAlbum(BuildContext context, Album album) async { + try { + final albumRepo = AlbumRepo(AlbumCachedDataSource()); + await UpdateAlbum(albumRepo)( + widget.account, + album.copyWith( + items: [...album.items, AlbumFileItem(file: widget.file)], + )); + } catch (e, stacktrace) { + _log.severe("[_addToAlbum] Failed while updating album", e, stacktrace); + SnackBarManager().showSnackBar(SnackBar( + content: Text( + "${AppLocalizations.of(context).addToAlbumFailureNotification}: " + "${exception_util.toUserString(e, context)}"), + duration: k.snackBarDurationNormal, + )); + rethrow; + } + } + + // metadata + int _width; + int _height; + // EXIF data + DateTime _dateTime; + String _model; + double _fNumber; + String _exposureTime; + double _focalLength; + int _isoSpeedRatings; + + static final _log = + Logger("widget.viewer_detail_pane._ViewerDetailPaneState"); +} + +class _DetailPaneButton extends StatelessWidget { + const _DetailPaneButton({Key key, this.icon, this.label, this.onPressed}) + : super(key: key); + + @override + build(BuildContext context) { + return TextButton( + onPressed: onPressed, + style: AppTheme.flatButtonStyle.copyWith( + foregroundColor: MaterialStateProperty.all(Colors.grey[700]), + ), + child: SizedBox( + width: 96, + height: 96, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Icon(icon), + const SizedBox(height: 4), + Text( + label, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + ), + ); + } + + final IconData icon; + final String label; + final VoidCallback onPressed; +} + +String _byteSizeToString(int byteSize) { + const units = ["B", "KB", "MB", "GB"]; + var remain = byteSize.toDouble(); + int i = 0; + while (i < units.length) { + final next = remain / 1024; + if (next < 1) { + break; + } + remain = next; + ++i; + } + return "${remain.toStringAsFixed(2)}${units[i]}"; +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 00000000..5f7ef656 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,576 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.2" + bloc: + dependency: "direct main" + description: + name: bloc + url: "https://pub.dartlang.org" + source: hosted + version: "7.0.0" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.15.0" + connectivity: + dependency: "direct main" + description: + name: connectivity + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.3" + connectivity_for_web: + dependency: "direct main" + description: + name: connectivity_for_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.0" + connectivity_macos: + dependency: transitive + description: + name: connectivity_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" + connectivity_platform_interface: + dependency: transitive + description: + name: connectivity_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + equatable: + dependency: "direct main" + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + event_bus: + dependency: "direct main" + description: + name: event_bus + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + exifdart: + dependency: "direct main" + description: + path: "." + ref: "1.0.0" + resolved-ref: "6d52c90f5e48dd1a2eeefb04b43d90b993387568" + url: "https://gitlab.com/nkming2/exifdart.git" + source: git + version: "1.0.0" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + url: "https://pub.dartlang.org" + source: hosted + version: "7.0.0" + flutter_blurhash: + dependency: transitive + description: + name: flutter_blurhash + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_staggered_grid_view: + dependency: "direct main" + description: + name: flutter_staggered_grid_view + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.4" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + hashcodes: + dependency: transitive + description: + name: hashcodes + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + http: + dependency: "direct main" + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.1" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" + idb_shim: + dependency: "direct main" + description: + name: idb_shim + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0+2" + idb_sqflite: + dependency: "direct main" + description: + name: idb_sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + image: + dependency: transitive + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + image_size_getter: + dependency: "direct main" + description: + path: library + ref: support-webp-extended-format + resolved-ref: "1f16c459f478ebbf902048382eda652a2b46a4d1" + url: "https://github.com/nkming2/dart_image_size_getter" + source: git + version: "1.0.0" + intl: + dependency: "direct main" + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.0" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.3" + kiwi: + dependency: "direct main" + description: + name: kiwi + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + logging: + dependency: "direct main" + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.10" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0+1" + page_view_indicators: + dependency: "direct main" + description: + name: page_view_indicators + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + path: + dependency: "direct main" + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + path_provider: + dependency: transitive + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.11.0" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.2" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.1" + provider: + dependency: transitive + description: + name: provider + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.0" + quiver: + dependency: transitive + description: + name: quiver + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + rxdart: + dependency: "direct main" + description: + name: rxdart + url: "https://pub.dartlang.org" + source: hosted + version: "0.26.0" + sembast: + dependency: transitive + description: + name: sembast + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0+4" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + shared_preferences_macos: + dependency: transitive + description: + name: shared_preferences_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + sqflite: + dependency: "direct main" + description: + name: sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0+2" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + synchronized: + dependency: "direct main" + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + tuple: + dependency: "direct main" + description: + name: tuple + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.3" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" + xml: + dependency: "direct main" + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.2" +sdks: + dart: ">=2.12.0 <3.0.0" + flutter: ">=1.24.0-10.2.pre" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..afefa15a --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,110 @@ +name: nc_photos +description: Gallery app for viewing your photos hosted on Nextcloud servers + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.4.0+40 + +environment: + sdk: ">=2.10.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + bloc: ^7.0.0 + cached_network_image: ^3.0.0 + connectivity: ^3.0.2 + connectivity_for_web: ^0.4.0 + equatable: ^2.0.0 + event_bus: ^2.0.0 + exifdart: + git: + url: https://gitlab.com/nkming2/exifdart.git + ref: 1.0.0 + flutter_bloc: ^7.0.0 + flutter_staggered_grid_view: ^0.3.3 + http: ^0.13.1 + idb_shim: ^2.0.0 + idb_sqflite: ^1.0.0 + # image_size_getter: ^1.0.0 + image_size_getter: + git: + url: https://github.com/nkming2/dart_image_size_getter + ref: support-webp-extended-format + path: library + intl: ^0.17.0 + kiwi: ^2.1.1 + logging: ^1.0.1 + page_view_indicators: ^2.0.0 + path: ^1.8.0 + rxdart: ^0.26.0 + shared_preferences: ^2.0.5 + sqflite: ^2.0.0 + synchronized: ^3.0.0 + tuple: ^2.0.0 + url_launcher: ^6.0.3 + xml: ^5.0.2 + +dev_dependencies: + # flutter_test: + # sdk: flutter + # integration_test: + # sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - assets/ + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages + + generate: true diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 00000000..97b3454b Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 00000000..408ff582 Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 00000000..b0a4bfbe Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..e285b426 --- /dev/null +++ b/web/index.html @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + Photos + + + + + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 00000000..30e331ca --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "Photos (for Nextcloud)", + "short_name": "Photos", + "start_url": ".", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#fafafa", + "description": "Gallery app for viewing your photos hosted on Nextcloud servers", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +}