Initial commit

This commit is contained in:
Ming Ming 2021-04-10 12:28:12 +08:00
commit ab573ad273
112 changed files with 10310 additions and 0 deletions

47
.gitignore vendored Normal file
View file

@ -0,0 +1,47 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
.vscode/
*.code-workspace
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Web related
lib/generated_plugin_registrant.dart
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

10
.metadata Normal file
View file

@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: 8f89f6505b941329a864fef1527243a72800bf4d
channel: beta
project_type: app

12
README.md Normal file
View file

@ -0,0 +1,12 @@
# Photos (for Nextcloud)
Photos (for Nextcloud) is a new gallery app for viewing your photos hosted on Nextcloud servers
[<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Google Play" width="160" />](https://play.google.com/store/apps/details?id=com.nkming.nc_photos)
Features:
- Sign-in to multiple servers
- EXIF support (JPEG only for now)
- Organize photos with albums that are independent of your file hierarchy
- and more to come!
This app does not require any server-side plugins.

14
android/.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
key.properties
/app/src/debug
/app/src/profile

81
android/app/build.gradle Normal file
View file

@ -0,0 +1,81 @@
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
ext.abiCodes = ["armeabi-v7a": 1, "arm64-v8a": 2, "x86_64": 3]
android {
compileSdkVersion 30
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.nkming.nc_photos"
minSdkVersion 19
targetSdkVersion 30
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
multiDexEnabled true
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
// signingConfig signingConfigs.debug
}
}
splits {
abi {
enable true
reset()
include "armeabi-v7a", "arm64-v8a", "x86_64"
universalApk true
}
}
applicationVariants.all { variant ->
variant.outputs.each { output ->
def baseAbiVersionCode = project.ext.abiCodes.get(output.getFilter(com.android.build.OutputFile.ABI))
if (baseAbiVersionCode != null) {
output.versionCodeOverride = baseAbiVersionCode + variant.versionCode
}
}
}
}
flutter {
source '../..'
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "androidx.multidex:multidex:2.0.1"
}

2
android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,2 @@
# To ensure that retracing stack traces is unambiguous
-keepattributes LineNumberTable,SourceFile

View file

@ -0,0 +1,50 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.nkming.nc_photos">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:name="androidx.multidex.MultiDexApplication"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- Displays an Android View that continues showing the launch screen
Drawable until Flutter paints its first frame, then this splash
screen fades out. A splash screen is useful to avoid any visual
gap between the end of Android's launch screen and the painting of
Flutter's first frame. -->
<meta-data
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>

View file

@ -0,0 +1,112 @@
package com.nkming.nc_photos
import android.Manifest
import android.app.Activity
import android.content.ContentValues
import android.content.pm.PackageManager
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.NonNull
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
private const val PERMISSION_REQUEST_CODE = 11011
class MainActivity: FlutterActivity() {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger,
MediaStoreChannelHandler.CHANNEL)
.setMethodCallHandler(MediaStoreChannelHandler(this))
}
}
class MediaStoreChannelHandler(activity: Activity)
: MethodChannel.MethodCallHandler {
companion object {
@JvmStatic
val CHANNEL = "com.nkming.nc_photos/media_store"
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method == "saveFileToDownload") {
saveFileToDownload(call.argument<String>("fileName")!!,
call.argument<ByteArray>("content")!!, result)
} else {
result.notImplemented()
}
}
private fun saveFileToDownload(fileName: String, content: ByteArray,
result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveFileToDownload29(fileName, content, result)
} else {
saveFileToDownload0(fileName, content, result)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveFileToDownload29(fileName: String, content: ByteArray,
result: MethodChannel.Result) {
// Add a media item that other apps shouldn't see until the item is
// fully written to the media store.
val resolver = _context.applicationContext.contentResolver
// Find all audio files on the primary external storage device.
val collection = MediaStore.Downloads.getContentUri(
MediaStore.VOLUME_EXTERNAL_PRIMARY)
val details = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
}
val contentUri = resolver.insert(collection, details)
resolver.openFileDescriptor(contentUri!!, "w", null).use { pfd ->
// Write data into the pending audio file.
BufferedOutputStream(FileOutputStream(pfd!!.fileDescriptor)).use {
stream -> stream.write(content)
}
}
result.success(null)
}
private fun saveFileToDownload0(fileName: String, content: ByteArray,
result: MethodChannel.Result) {
if (ContextCompat.checkSelfPermission(_activity,
Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(_activity,
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
PERMISSION_REQUEST_CODE)
result.error("permissionError", "Permission not granted", null)
return
}
val path = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS)
var file = File(path, fileName)
var count = 1
while (file.exists()) {
val f = File(fileName)
file = File(path, "${f.nameWithoutExtension} ($count).${f.extension}")
++count
}
BufferedOutputStream(FileOutputStream(file)).use {
stream -> stream.write(content)
}
result.success(null)
}
private val _activity = activity
private val _context get() = _activity
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#d3efff" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Photos</string>
</resources>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

31
android/build.gradle Normal file
View file

@ -0,0 +1,31 @@
buildscript {
ext.kotlin_version = '1.3.50'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
jcenter()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View file

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=true

View file

@ -0,0 +1,6 @@
#Fri Jun 23 08:50:38 CEST 2017
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip

11
android/settings.gradle Normal file
View file

@ -0,0 +1,11 @@
include ':app'
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

3
l10n.yaml Normal file
View file

@ -0,0 +1,3 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart

76
lib/account.dart Normal file
View file

@ -0,0 +1,76 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:nc_photos/string_extension.dart';
/// Details of a remote Nextcloud server account
class Account with EquatableMixin {
Account(
this.scheme,
String address,
this.username,
this.password,
List<String> roots,
) : this.address = address.trimRightAny("/"),
_roots = roots.map((e) => e.trimRightAny("/")).toList() {
if (scheme != "http" && scheme != "https") {
throw FormatException("scheme is neither http or https");
}
}
Account copyWith({
String scheme,
String address,
String username,
String password,
List<String> roots,
}) {
return Account(
scheme ?? this.scheme,
address ?? this.address,
username ?? this.username,
password ?? this.password,
roots ?? _roots,
);
}
@override
toString() {
return "$runtimeType {"
"scheme: '$scheme', "
"address: '$address', "
"username: '$username', "
"password: '${password?.isNotEmpty == true ? (kDebugMode ? password : '***') : null}', "
"roots: List {'${roots.join('\', \'')}'}, "
"}";
}
Account.fromJson(Map<String, dynamic> json)
: scheme = json["scheme"],
address = json["address"],
username = json["username"],
password = json["password"],
_roots = json["roots"].cast<String>();
Map<String, dynamic> toJson() => {
"scheme": scheme,
"address": address,
"username": username,
"password": password,
"roots": _roots,
};
@override
List<Object> get props => [scheme, address, username, password, _roots];
List<String> get roots => _roots;
final String scheme;
final String address;
final String username;
final String password;
final List<String> _roots;
}
extension AccountExtension on Account {
String get url => "$scheme://$address";
}

328
lib/api/api.dart Normal file
View file

@ -0,0 +1,328 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:xml/xml.dart';
class Response {
Response(this.statusCode, this.headers, this.body);
bool get isGood => _isHttpStatusGood(statusCode);
@override
String toString() {
return "{"
"status: $statusCode, "
"headers: ..., "
"body: ..., "
"}";
}
final int statusCode;
final Map<String, String> headers;
/// Content of the response body, String if isResponseString == true during
/// request, Uint8List otherwise
final dynamic body;
}
class Api {
Api(this._account);
_Files files() => _Files(this);
static String getAuthorizationHeaderValue(Account account) {
final auth =
base64.encode(utf8.encode("${account.username}:${account.password}"));
return "Basic $auth";
}
Future<Response> request(
String method,
String endpoint, {
Map<String, String> header,
String body,
Uint8List bodyBytes,
bool isResponseString = true,
}) async {
Uri url;
if (_account.scheme == "http") {
url = Uri.http(_account.address, endpoint);
} else {
url = Uri.https(_account.address, endpoint);
}
final req = http.Request(method, url)
..headers.addAll({
"authorization": getAuthorizationHeaderValue(_account),
});
if (header != null) {
// turn all to lower case, since HTTP headers are case-insensitive, this
// smooths our processing (if any)
req.headers.addEntries(
header.entries.map((e) => MapEntry(e.key.toLowerCase(), e.value)));
}
if (body != null) {
if (!req.headers.containsKey("content-type")) {
req.headers["content-type"] = "application/xml";
}
req.body = body;
} else if (bodyBytes != null) {
if (!req.headers.containsKey("content-type")) {
req.headers["content-type"] = "application/octet-stream";
}
req.bodyBytes = bodyBytes;
}
final response =
await http.Response.fromStream(await http.Client().send(req));
if (!_isHttpStatusGood(response.statusCode)) {
_log.severe(
"[request] HTTP $method (${response.statusCode}): $endpoint",
response.body,
);
}
return Response(response.statusCode, response.headers,
isResponseString ? response.body : response.bodyBytes);
}
final Account _account;
static final _log = Logger("api.api.Api");
}
bool _isHttpStatusGood(int status) => status ~/ 100 == 2;
class _Files {
_Files(this._api);
Api _api;
Future<Response> delete({
@required String path,
}) async {
try {
return await _api.request("DELETE", path);
} catch (e) {
_log.severe("[delete] Failed while delete", e);
rethrow;
}
}
Future<Response> get({
@required String path,
}) async {
try {
return await _api.request("GET", path, isResponseString: false);
} catch (e) {
_log.severe("[get] Failed while get", e);
rethrow;
}
}
Future<Response> put({
@required String path,
String mime = "application/octet-stream",
Uint8List content,
}) async {
try {
return await _api.request(
"PUT",
path,
header: {
"content-type": mime,
},
bodyBytes: content,
);
} catch (e) {
_log.severe("[put] Failed while put", e);
rethrow;
}
}
Future<Response> propfind({
@required String path,
int depth,
getlastmodified,
getetag,
getcontenttype,
resourcetype,
getcontentlength,
id,
fileid,
favorite,
commentsHref,
commentsCount,
commentsUnread,
ownerId,
ownerDisplayName,
shareTypes,
checksums,
hasPreview,
size,
richWorkspace,
Map<String, String> customNamespaces,
List<String> customProperties,
}) async {
try {
final bool hasDavNs = (getlastmodified != null ||
getetag != null ||
getcontenttype != null ||
resourcetype != null ||
getcontentlength != null);
final bool hasOcNs = (id != null ||
fileid != null ||
favorite != null ||
commentsHref != null ||
commentsCount != null ||
commentsUnread != null ||
ownerId != null ||
ownerDisplayName != null ||
shareTypes != null ||
checksums != null ||
size != null);
final bool hasNcNs = (hasPreview != null || richWorkspace != null);
if (!hasDavNs && !hasOcNs && !hasNcNs) {
// no body
return await _api.request("PROPFIND", path);
}
final namespaces = <String, String>{
"DAV:": "d",
if (hasOcNs) "http://owncloud.org/ns": "oc",
if (hasNcNs) "http://nextcloud.org/ns": "nc",
}..addAll(customNamespaces ?? {});
final builder = XmlBuilder();
builder
..processing("xml", "version=\"1.0\"")
..element("d:propfind", namespaces: namespaces, nest: () {
builder.element("d:prop", nest: () {
if (getlastmodified != null) {
builder.element("d:getlastmodified");
}
if (getetag != null) {
builder.element("d:getetag");
}
if (getcontenttype != null) {
builder.element("d:getcontenttype");
}
if (resourcetype != null) {
builder.element("d:resourcetype");
}
if (getcontentlength != null) {
builder.element("d:getcontentlength");
}
if (id != null) {
builder.element("oc:id");
}
if (fileid != null) {
builder.element("oc:fileid");
}
if (favorite != null) {
builder.element("oc:favorite");
}
if (commentsHref != null) {
builder.element("oc:comments-href");
}
if (commentsCount != null) {
builder.element("oc:comments-count");
}
if (commentsUnread != null) {
builder.element("oc:comments-unread");
}
if (ownerId != null) {
builder.element("oc:owner-id");
}
if (ownerDisplayName != null) {
builder.element("oc:owner-display-name");
}
if (shareTypes != null) {
builder.element("oc:share-types");
}
if (checksums != null) {
builder.element("oc:checksums");
}
if (size != null) {
builder.element("oc:size");
}
if (hasPreview != null) {
builder.element("nc:has-preview");
}
if (richWorkspace != null) {
builder.element("nc:rich-workspace");
}
for (final p in customProperties) {
builder.element(p);
}
});
});
return await _api.request("PROPFIND", path,
body: builder.buildDocument().toXmlString());
} catch (e) {
_log.severe("[propfind] Failed while propfind", e);
rethrow;
}
}
/// Set or remove custom properties
///
/// [namespaces] should be specified in the format {"URI": "prefix"}, eg,
/// {"DAV:": "d"}
Future<Response> proppatch({
@required String path,
Map<String, String> namespaces,
Map<String, dynamic> set,
List<String> remove,
}) async {
try {
final ns = <String, String>{
"DAV:": "d",
}..addAll(namespaces ?? {});
final builder = XmlBuilder();
builder
..processing("xml", "version=\"1.0\"")
..element("d:propertyupdate", namespaces: ns, nest: () {
if (set?.isNotEmpty == true) {
builder.element("d:set", nest: () {
builder.element("d:prop", nest: () {
for (final e in set.entries) {
builder.element("${e.key}", nest: () {
builder.text("${e.value}");
});
}
});
});
}
if (remove?.isNotEmpty == true) {
builder.element("d:remove", nest: () {
builder.element("d:prop", nest: () {
for (final e in remove) {
builder.element("$e");
}
});
});
}
});
return await _api.request("PROPPATCH", path,
body: builder.buildDocument().toXmlString());
} catch (e) {
_log.severe("[proppatch] Failed while proppatch", e);
rethrow;
}
}
/// A folder can be created by sending a MKCOL request to the folder
Future<Response> mkcol({
@required String path,
}) async {
try {
return await _api.request("MKCOL", path);
} catch (e) {
_log.severe("[mkcol] Failed while get", e);
rethrow;
}
}
static final _log = Logger("api.api._Files");
}

81
lib/api/api_util.dart Normal file
View file

@ -0,0 +1,81 @@
/// Helper functions working with remote Nextcloud server
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/entity/file.dart';
/// Return the preview image URL for [file]. See [getFilePreviewUrlRelative]
String getFilePreviewUrl(
Account account,
File file, {
@required int width,
@required int height,
String mode,
bool a,
}) {
return "${account.url}/"
"${getFilePreviewUrlRelative(file, width: width, height: height, mode: mode, a: a)}";
}
/// Return the relative preview image URL for [file]. If [a] == true, the
/// preview will maintain the original aspect ratio, otherwise it will be
/// cropped
String getFilePreviewUrlRelative(
File file, {
@required int width,
@required int height,
String mode,
bool a,
}) {
final filePath = Uri.encodeQueryComponent(file.strippedPath);
var url = "core/preview.png?file=$filePath&x=$width&y=$height";
if (mode != null) {
url = "$url&mode=$mode";
}
if (a != null) {
url = "$url&a=${a ? 1 : 0}";
}
return url;
}
String getFileUrl(Account account, File file) {
return "${account.url}/${getFileUrlRelative(file)}";
}
String getFileUrlRelative(File file) {
return file.path;
}
String getWebdavRootUrlRelative(Account account) =>
"remote.php/dav/files/${account.username}";
/// Query the app password for [account]
Future<String> exchangePassword(Account account) async {
final response = await Api(account).request(
"GET",
"ocs/v2.php/core/getapppassword",
header: {
"OCS-APIRequest": "true",
},
);
if (response.isGood) {
final appPwdRegex = RegExp(r"<apppassword>(.*)</apppassword>");
final appPwdMatch = appPwdRegex.firstMatch(response.body);
return appPwdMatch.group(1);
} else if (response.statusCode == 403) {
// If the client is authenticated with an app password a 403 will be
// returned
_log.info("[exchangePassword] Already an app password");
return account.password;
} else {
_log.severe(
"[exchangePassword] Failed while requesting app password: $response");
throw HttpException(
"Failed communicating with server: ${response.statusCode}");
}
}
final _log = Logger("api.api_util");

43
lib/app_db.dart Normal file
View file

@ -0,0 +1,43 @@
import 'dart:async';
import 'package:idb_shim/idb.dart';
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
import 'package:synchronized/synchronized.dart';
class AppDb {
static const dbName = "app.db";
static const fileStoreName = "files";
static const albumStoreName = "albums";
/// Run [fn] with an opened database instance
///
/// This function guarantees that:
/// 1) Database is always closed after [fn] exits, even with an error
/// 2) Only at most 1 database instance being opened at any time
static Future<T> use<T>(FutureOr<T> Function(Database) fn) async {
// make sure only one client is opening the db
return await _lock.synchronized(() async {
final db = await _open();
try {
return await fn(db);
} finally {
db.close();
}
});
}
/// Open the database
static Future<Database> _open() async {
final dbFactory = platform.MyApp.getDbFactory();
return dbFactory.open(dbName, version: 1, onUpgradeNeeded: (event) {
final db = event.database;
if (event.oldVersion < 1) {
db.createObjectStore(fileStoreName);
db.createObjectStore(albumStoreName);
}
});
}
static final _lock = Lock(reentrant: true);
}

View file

@ -0,0 +1,84 @@
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
abstract class AppPasswordExchangeBlocEvent {
const AppPasswordExchangeBlocEvent();
}
class AppPasswordExchangeBlocConnect extends AppPasswordExchangeBlocEvent {
const AppPasswordExchangeBlocConnect(this.account);
@override
toString() {
return "$runtimeType {"
"account: $account, "
"}";
}
final Account account;
}
abstract class AppPasswordExchangeBlocState {
const AppPasswordExchangeBlocState();
}
class AppPasswordExchangeBlocInit extends AppPasswordExchangeBlocState {
const AppPasswordExchangeBlocInit();
}
class AppPasswordExchangeBlocSuccess extends AppPasswordExchangeBlocState {
const AppPasswordExchangeBlocSuccess(this.password);
@override
toString() {
return "$runtimeType {"
"password: ${kDebugMode ? password : '***'}, "
"}";
}
final String password;
}
class AppPasswordExchangeBlocFailure extends AppPasswordExchangeBlocState {
const AppPasswordExchangeBlocFailure(this.exception);
@override
toString() {
return "$runtimeType {"
"exception: $exception, "
"}";
}
final exception;
}
class AppPasswordExchangeBloc
extends Bloc<AppPasswordExchangeBlocEvent, AppPasswordExchangeBlocState> {
AppPasswordExchangeBloc() : super(AppPasswordExchangeBlocInit());
@override
mapEventToState(AppPasswordExchangeBlocEvent event) async* {
_log.info("[mapEventToState] $event");
if (event is AppPasswordExchangeBlocConnect) {
yield* _exchangePassword(event.account);
}
}
Stream<AppPasswordExchangeBlocState> _exchangePassword(
Account account) async* {
try {
final appPwd = await api_util.exchangePassword(account);
yield AppPasswordExchangeBlocSuccess(appPwd);
} catch (e, stacktrace) {
_log.severe("[_exchangePassword] Failed while exchanging password", e,
stacktrace);
yield AppPasswordExchangeBlocFailure(e);
}
}
static final _log =
Logger("bloc.app_password_exchange.AppPasswordExchangeBloc");
}

222
lib/bloc/list_album.dart Normal file
View file

@ -0,0 +1,222 @@
import 'package:bloc/bloc.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/use_case/list_album.dart';
abstract class ListAlbumBlocEvent {
const ListAlbumBlocEvent();
}
class ListAlbumBlocQuery extends ListAlbumBlocEvent {
const ListAlbumBlocQuery(this.account);
@override
toString() {
return "$runtimeType {"
"account: $account, "
"}";
}
final Account account;
}
/// An external event has happened and may affect the state of this bloc
class _ListAlbumBlocExternalEvent extends ListAlbumBlocEvent {
const _ListAlbumBlocExternalEvent();
@override
toString() {
return "$runtimeType {"
"}";
}
}
abstract class ListAlbumBlocState {
const ListAlbumBlocState(this.account, this.albums);
@override
toString() {
return "$runtimeType {"
"account: $account, "
"albums: List {length: ${albums.length}}, "
"}";
}
final Account account;
final List<Album> albums;
}
class ListAlbumBlocInit extends ListAlbumBlocState {
const ListAlbumBlocInit() : super(null, const []);
}
class ListAlbumBlocLoading extends ListAlbumBlocState {
const ListAlbumBlocLoading(Account account, List<Album> albums)
: super(account, albums);
}
class ListAlbumBlocSuccess extends ListAlbumBlocState {
const ListAlbumBlocSuccess(Account account, List<Album> albums)
: super(account, albums);
}
class ListAlbumBlocFailure extends ListAlbumBlocState {
const ListAlbumBlocFailure(
Account account, List<Album> albums, this.exception)
: super(account, albums);
@override
toString() {
return "$runtimeType {"
"super: ${super.toString()}, "
"exception: $exception, "
"}";
}
final dynamic exception;
}
/// The state of this bloc is inconsistent. This typically means that the data
/// may have been changed externally
class ListAlbumBlocInconsistent extends ListAlbumBlocState {
const ListAlbumBlocInconsistent(Account account, List<Album> albums)
: super(account, albums);
}
class ListAlbumBloc extends Bloc<ListAlbumBlocEvent, ListAlbumBlocState> {
ListAlbumBloc() : super(ListAlbumBlocInit()) {
_fileMetadataUpdatedListener =
AppEventListener<FileMetadataUpdatedEvent>(_onFileMetadataUpdatedEvent);
_albumUpdatedListener =
AppEventListener<AlbumUpdatedEvent>(_onAlbumUpdatedEvent);
_fileRemovedListener =
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
_albumCreatedListener =
AppEventListener<AlbumCreatedEvent>(_onAlbumCreatedEvent);
_fileMetadataUpdatedListener.begin();
_albumUpdatedListener.begin();
_fileRemovedListener.begin();
_albumCreatedListener.begin();
}
@override
mapEventToState(ListAlbumBlocEvent event) async* {
_log.info("[mapEventToState] $event");
if (event is ListAlbumBlocQuery) {
yield* _onEventQuery(event);
} else if (event is _ListAlbumBlocExternalEvent) {
yield* _onExternalEvent(event);
}
}
@override
close() {
_fileMetadataUpdatedListener.end();
_albumUpdatedListener.end();
_fileRemovedListener.end();
_albumCreatedListener.end();
return super.close();
}
Stream<ListAlbumBlocState> _onEventQuery(ListAlbumBlocQuery ev) async* {
yield ListAlbumBlocLoading(ev.account, state.albums);
ListAlbumBlocState cacheState = ListAlbumBlocInit();
await for (final s in _queryOffline(ev, () => cacheState)) {
cacheState = s;
}
yield ListAlbumBlocLoading(ev.account, cacheState.albums);
ListAlbumBlocState newState = ListAlbumBlocInit();
if (cacheState.albums.isEmpty) {
await for (final s in _queryOnline(ev, () => newState)) {
newState = s;
yield s;
}
} else {
await for (final s in _queryOnline(ev, () => newState)) {
newState = s;
}
if (newState is ListAlbumBlocSuccess) {
yield newState;
} else if (newState is ListAlbumBlocFailure) {
yield ListAlbumBlocFailure(
ev.account, cacheState.albums, newState.exception);
}
}
}
Stream<ListAlbumBlocState> _onExternalEvent(
_ListAlbumBlocExternalEvent ev) async* {
yield ListAlbumBlocInconsistent(state.account, state.albums);
}
void _onFileMetadataUpdatedEvent(FileMetadataUpdatedEvent ev) {
if (state is ListAlbumBlocInit) {
// no data in this bloc, ignore
return;
}
add(_ListAlbumBlocExternalEvent());
}
void _onAlbumUpdatedEvent(AlbumUpdatedEvent ev) {
if (state is ListAlbumBlocInit) {
// no data in this bloc, ignore
return;
}
add(_ListAlbumBlocExternalEvent());
}
void _onFileRemovedEvent(FileRemovedEvent ev) {
if (state is ListAlbumBlocInit) {
// no data in this bloc, ignore
return;
}
if (isAlbumFile(ev.file)) {
add(_ListAlbumBlocExternalEvent());
}
}
void _onAlbumCreatedEvent(AlbumCreatedEvent ev) {
if (state is ListAlbumBlocInit) {
// no data in this bloc, ignore
return;
}
add(_ListAlbumBlocExternalEvent());
}
Stream<ListAlbumBlocState> _queryOffline(
ListAlbumBlocQuery ev, ListAlbumBlocState Function() getState) =>
_queryWithAlbumDataSource(
ev, getState, FileAppDbDataSource(), AlbumAppDbDataSource());
Stream<ListAlbumBlocState> _queryOnline(
ListAlbumBlocQuery ev, ListAlbumBlocState Function() getState) =>
_queryWithAlbumDataSource(
ev, getState, FileCachedDataSource(), AlbumCachedDataSource());
Stream<ListAlbumBlocState> _queryWithAlbumDataSource(
ListAlbumBlocQuery ev,
ListAlbumBlocState Function() getState,
FileDataSource fileDataSource,
AlbumDataSource albumDataSrc) async* {
try {
final results = await ListAlbum(
FileRepo(fileDataSource), AlbumRepo(albumDataSrc))(ev.account);
yield ListAlbumBlocSuccess(ev.account, results);
} catch (e) {
_log.severe("[_queryWithAlbumDataSource] Exception while request", e);
yield ListAlbumBlocFailure(ev.account, getState().albums, e);
}
}
AppEventListener<FileMetadataUpdatedEvent> _fileMetadataUpdatedListener;
AppEventListener<AlbumUpdatedEvent> _albumUpdatedListener;
AppEventListener<FileRemovedEvent> _fileRemovedListener;
AppEventListener<AlbumCreatedEvent> _albumCreatedListener;
static final _log = Logger("bloc.list_album.ListAlbumBloc");
}

126
lib/bloc/ls_dir.dart Normal file
View file

@ -0,0 +1,126 @@
import 'dart:io';
import 'package:bloc/bloc.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/use_case/ls.dart';
class LsDirBlocItem {
LsDirBlocItem(this.file, this.children);
File file;
/// Child directories under this directory, or null if this isn't a directory
List<LsDirBlocItem> children;
}
abstract class LsDirBlocEvent {
const LsDirBlocEvent();
}
class LsDirBlocQuery extends LsDirBlocEvent {
const LsDirBlocQuery(this.account, this.roots);
@override
toString() {
return "$runtimeType {"
"account: $account, "
"roots: ${roots.map((e) => e.path).toReadableString()}, "
"}";
}
final Account account;
final List<File> roots;
}
abstract class LsDirBlocState {
const LsDirBlocState(this._account, this._items);
Account get account => _account;
List<LsDirBlocItem> get items => _items;
@override
toString() {
return "$runtimeType {"
"account: $account, "
"items: List {length: ${items.length}}, "
"}";
}
final Account _account;
final List<LsDirBlocItem> _items;
}
class LsDirBlocInit extends LsDirBlocState {
const LsDirBlocInit() : super(null, const []);
}
class LsDirBlocLoading extends LsDirBlocState {
const LsDirBlocLoading(Account account, List<LsDirBlocItem> items)
: super(account, items);
}
class LsDirBlocSuccess extends LsDirBlocState {
const LsDirBlocSuccess(Account account, List<LsDirBlocItem> items)
: super(account, items);
}
class LsDirBlocFailure extends LsDirBlocState {
const LsDirBlocFailure(
Account account, List<LsDirBlocItem> items, this.exception)
: super(account, items);
@override
toString() {
return "$runtimeType {"
"super: ${super.toString()}, "
"exception: $exception, "
"}";
}
final dynamic exception;
}
/// A bloc that return all directories under a dir recursively
class LsDirBloc extends Bloc<LsDirBlocEvent, LsDirBlocState> {
LsDirBloc() : super(LsDirBlocInit());
@override
mapEventToState(LsDirBlocEvent event) async* {
_log.info("[mapEventToState] $event");
if (event is LsDirBlocQuery) {
yield* _onEventQuery(event);
}
}
Stream<LsDirBlocState> _onEventQuery(LsDirBlocQuery ev) async* {
try {
yield LsDirBlocLoading(ev.account, state.items);
final products = <LsDirBlocItem>[];
for (final r in ev.roots) {
products.addAll(await _query(ev, r));
}
yield LsDirBlocSuccess(ev.account, products);
} catch (e) {
_log.severe("[_onEventQuery] Exception while request", e);
yield LsDirBlocFailure(ev.account, state.items, e);
}
}
Future<List<LsDirBlocItem>> _query(LsDirBlocQuery ev, File root) async {
final products = <LsDirBlocItem>[];
final files = await Ls(FileRepo(FileWebdavDataSource()))(ev.account, root);
for (final f in files) {
if (f.isCollection) {
products.add(LsDirBlocItem(f, await _query(ev, f)));
}
// we don't want normal files
}
return products;
}
static final _log = Logger("bloc.ls_dir.LsDirBloc");
}

205
lib/bloc/scan_dir.dart Normal file
View file

@ -0,0 +1,205 @@
import 'dart:io';
import 'package:bloc/bloc.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/use_case/scan_dir.dart';
abstract class ScanDirBlocEvent {
const ScanDirBlocEvent();
}
class ScanDirBlocQuery extends ScanDirBlocEvent {
const ScanDirBlocQuery(this.account, this.roots);
@override
toString() {
return "$runtimeType {"
"account: $account, "
"roots: ${roots.map((e) => e.path).toReadableString()}, "
"}";
}
final Account account;
final List<File> roots;
}
/// An external event has happened and may affect the state of this bloc
class _ScanDirBlocExternalEvent extends ScanDirBlocEvent {
const _ScanDirBlocExternalEvent();
@override
toString() {
return "$runtimeType {"
"}";
}
}
abstract class ScanDirBlocState {
const ScanDirBlocState(this._account, this._files);
Account get account => _account;
List<File> get files => _files;
@override
toString() {
return "$runtimeType {"
"account: $account, "
"files: List {length: ${files.length}}, "
"}";
}
final Account _account;
final List<File> _files;
}
class ScanDirBlocInit extends ScanDirBlocState {
const ScanDirBlocInit() : super(null, const []);
}
class ScanDirBlocLoading extends ScanDirBlocState {
const ScanDirBlocLoading(Account account, List<File> files)
: super(account, files);
}
class ScanDirBlocSuccess extends ScanDirBlocState {
const ScanDirBlocSuccess(Account account, List<File> files)
: super(account, files);
}
class ScanDirBlocFailure extends ScanDirBlocState {
const ScanDirBlocFailure(Account account, List<File> files, this.exception)
: super(account, files);
@override
toString() {
return "$runtimeType {"
"super: ${super.toString()}, "
"exception: $exception, "
"}";
}
final dynamic exception;
}
/// The state of this bloc is inconsistent. This typically means that the data
/// may have been changed externally
class ScanDirBlocInconsistent extends ScanDirBlocState {
const ScanDirBlocInconsistent(Account account, List<File> files)
: super(account, files);
}
/// A bloc that return all files under a dir recursively
///
/// See [ScanDir]
class ScanDirBloc extends Bloc<ScanDirBlocEvent, ScanDirBlocState> {
ScanDirBloc() : super(ScanDirBlocInit()) {
_fileRemovedEventListener =
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
_fileMetadataUpdatedEventListener =
AppEventListener<FileMetadataUpdatedEvent>(_onFileMetadataUpdatedEvent);
_fileRemovedEventListener.begin();
_fileMetadataUpdatedEventListener.begin();
}
@override
mapEventToState(ScanDirBlocEvent event) async* {
_log.info("[mapEventToState] $event");
if (event is ScanDirBlocQuery) {
yield* _onEventQuery(event);
} else if (event is _ScanDirBlocExternalEvent) {
yield* _onExternalEvent(event);
}
}
@override
close() {
_fileRemovedEventListener.end();
_fileMetadataUpdatedEventListener.end();
return super.close();
}
Stream<ScanDirBlocState> _onEventQuery(ScanDirBlocQuery ev) async* {
yield ScanDirBlocLoading(ev.account, state.files);
ScanDirBlocState cacheState = ScanDirBlocInit();
await for (final s in _queryOffline(ev, () => cacheState)) {
cacheState = s;
}
yield ScanDirBlocLoading(ev.account, cacheState.files);
ScanDirBlocState newState = ScanDirBlocInit();
if (cacheState.files.isEmpty) {
await for (final s in _queryOnline(ev, () => newState)) {
newState = s;
yield s;
}
} else {
await for (final s in _queryOnline(ev, () => newState)) {
newState = s;
}
if (newState is ScanDirBlocSuccess) {
yield newState;
} else if (newState is ScanDirBlocFailure) {
yield ScanDirBlocFailure(
ev.account, cacheState.files, newState.exception);
}
}
}
Stream<ScanDirBlocState> _onExternalEvent(
_ScanDirBlocExternalEvent ev) async* {
yield ScanDirBlocInconsistent(state.account, state.files);
}
void _onFileRemovedEvent(FileRemovedEvent ev) {
if (state is ScanDirBlocInit) {
// no data in this bloc, ignore
return;
}
add(_ScanDirBlocExternalEvent());
}
void _onFileMetadataUpdatedEvent(FileMetadataUpdatedEvent ev) {
if (state is ScanDirBlocInit) {
// no data in this bloc, ignore
return;
}
add(_ScanDirBlocExternalEvent());
}
Stream<ScanDirBlocState> _queryOffline(
ScanDirBlocQuery ev, ScanDirBlocState Function() getState) =>
_queryWithFileDataSource(ev, getState, FileAppDbDataSource());
Stream<ScanDirBlocState> _queryOnline(
ScanDirBlocQuery ev, ScanDirBlocState Function() getState) =>
_queryWithFileDataSource(ev, getState, FileCachedDataSource());
Stream<ScanDirBlocState> _queryWithFileDataSource(ScanDirBlocQuery ev,
ScanDirBlocState Function() getState, FileDataSource dataSrc) async* {
try {
for (final r in ev.roots) {
final dataStream = ScanDir(FileRepo(dataSrc))(ev.account, r);
await for (final d in dataStream) {
if (d is Exception || d is Error) {
throw d;
}
yield ScanDirBlocLoading(ev.account, getState().files + d);
}
}
yield ScanDirBlocSuccess(ev.account, getState().files);
} catch (e) {
_log.severe("[_queryWithFileDataSource] Exception while request", e);
yield ScanDirBlocFailure(ev.account, getState().files, e);
}
}
AppEventListener<FileRemovedEvent> _fileRemovedEventListener;
AppEventListener<FileMetadataUpdatedEvent> _fileMetadataUpdatedEventListener;
static final _log = Logger("bloc.scan_dir.ScanDirBloc");
}

View file

@ -0,0 +1,27 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
// ignore: implementation_imports
import 'package:flutter_cache_manager/src/cache_store.dart';
class CancelableGetFile {
CancelableGetFile(this.store);
Future<FileInfo> getFileUntil(String key,
{bool ignoreMemCache = false}) async {
FileInfo product;
while (product == null && _shouldRun) {
product = await store.getFile(key, ignoreMemCache: ignoreMemCache);
await Future.delayed(Duration(milliseconds: 500));
}
return product ?? Future.error("Interrupted");
}
void cancel() {
_shouldRun = false;
}
bool get isGood => _shouldRun;
final CacheStore store;
bool _shouldRun = true;
}

View file

@ -0,0 +1,11 @@
import 'package:connectivity/connectivity.dart';
Future<void> waitUntilWifi() async {
while (true) {
final result = await Connectivity().checkConnectivity();
if (result == ConnectivityResult.wifi) {
return;
}
await Future.delayed(const Duration(seconds: 5));
}
}

17
lib/double_extension.dart Normal file
View file

@ -0,0 +1,17 @@
import 'package:nc_photos/string_extension.dart';
extension DoubleExtension on double {
/// Same as toStringAsFixed but with trailing zeros truncated
String toStringAsFixedTruncated(int fractionDigits) {
String tmp = toStringAsFixed(fractionDigits);
if (fractionDigits == 0) {
return tmp;
}
tmp = tmp.trimRightAny("0");
if (tmp.endsWith(".")) {
return tmp.substring(0, tmp.length - 1);
} else {
return tmp;
}
}
}

426
lib/entity/album.dart Normal file
View file

@ -0,0 +1,426 @@
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:idb_sqflite/idb_sqflite.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/use_case/get_file_binary.dart';
import 'package:nc_photos/use_case/ls.dart';
import 'package:nc_photos/use_case/put_file_binary.dart';
import 'package:path/path.dart' as path;
String getAlbumFileRoot(Account account) =>
"${api_util.getWebdavRootUrlRelative(account)}/.com.nkming.nc_photos";
bool isAlbumFile(File file) =>
path.basename(path.dirname(file.path)) == ".com.nkming.nc_photos";
abstract class AlbumItem {
AlbumItem();
factory AlbumItem.fromJson(Map<String, dynamic> json) {
final type = json["type"];
final content = json["content"];
switch (type) {
case AlbumFileItem._type:
return AlbumFileItem.fromJson(content.cast<String, dynamic>());
default:
_log.severe("[fromJson] Unknown type: $type");
throw ArgumentError.value(type, "type");
}
}
Map<String, dynamic> toJson() {
String getType() {
if (this is AlbumFileItem) {
return AlbumFileItem._type;
} else {
throw StateError("Unknwon subtype");
}
}
return {
"type": getType(),
"content": toContentJson(),
};
}
Map<String, dynamic> toContentJson();
static final _log = Logger("entity.album.AlbumItem");
}
class AlbumFileItem extends AlbumItem {
AlbumFileItem({this.file});
factory AlbumFileItem.fromJson(Map<String, dynamic> json) {
return AlbumFileItem(
file: File.fromJson(json["file"].cast<String, dynamic>()),
);
}
@override
toString() {
return "$runtimeType {"
"file: $file"
"}";
}
@override
toContentJson() {
return {
"file": file.toJson(),
};
}
final File file;
static const _type = "file";
}
/// Immutable object that represents an album
class Album {
Album({
DateTime lastUpdated,
@required String name,
@required List<AlbumItem> items,
this.albumFile,
}) : this.lastUpdated = (lastUpdated ?? DateTime.now()).toUtc(),
this.name = name ?? "",
this.items = UnmodifiableListView(items);
factory Album.versioned({
int version,
DateTime lastUpdated,
@required String name,
@required List<AlbumItem> items,
File albumFile,
}) {
// there's only one version right now
return Album(
lastUpdated: lastUpdated,
name: name,
items: items,
albumFile: albumFile,
);
}
factory Album.fromJson(Map<String, dynamic> json) {
return Album.versioned(
version: json["version"],
lastUpdated: json["lastUpdated"] == null
? null
: DateTime.parse(json["lastUpdated"]),
name: json["name"],
items: (json["items"] as List)
.map((e) => AlbumItem.fromJson(e.cast<String, dynamic>()))
.toList(),
albumFile: json["albumFile"] == null
? null
: File.fromJson(json["albumFile"].cast<String, dynamic>()),
);
}
@override
toString() {
return "$runtimeType {"
"lastUpdated: $lastUpdated, "
"name: $name, "
"items: ${items.toReadableString()}, "
"albumFile: $albumFile, "
"}";
}
/// Return a copy with specified field modified
///
/// [lastUpdated] is handled differently where if null, the current time will
/// be used. In order to keep [lastUpdated], you must explicitly assign it
/// with value from this
Album copyWith({
DateTime lastUpdated,
String name,
List<AlbumItem> items,
File albumFile,
}) {
return Album(
lastUpdated: lastUpdated,
name: name ?? this.name,
items: items ?? this.items,
albumFile: albumFile ?? this.albumFile,
);
}
Map<String, dynamic> _toRemoteJson() {
return {
"version": version,
"lastUpdated": lastUpdated.toIso8601String(),
"name": name,
"items": items.map((e) => e.toJson()).toList(),
// ignore albumFile
};
}
Map<String, dynamic> _toAppDbJson() {
return {
"version": version,
"lastUpdated": lastUpdated.toIso8601String(),
"name": name,
"items": items.map((e) => e.toJson()).toList(),
if (albumFile != null) "albumFile": albumFile.toJson(),
};
}
final DateTime lastUpdated;
final String name;
/// Immutable list of items. Modifying the list will result in an error
final List<AlbumItem> items;
/// How is this album stored on server
///
/// This field is typically only meaningful when returned by [AlbumRepo.get]
final File albumFile;
/// versioning of this class, use to upgrade old persisted album
static const version = 1;
}
class AlbumRepo {
AlbumRepo(this.dataSrc);
/// See [AlbumDataSource.get]
Future<Album> get(Account account, File albumFile) =>
this.dataSrc.get(account, albumFile);
/// See [AlbumDataSource.create]
Future<Album> create(Account account, Album album) =>
this.dataSrc.create(account, album);
/// See [AlbumDataSource.update]
Future<void> update(Account account, Album album) =>
this.dataSrc.update(account, album);
/// See [AlbumDataSource.cleanUp]
Future<void> cleanUp(Account account, List<File> albumFiles) =>
this.dataSrc.cleanUp(account, albumFiles);
final AlbumDataSource dataSrc;
}
abstract class AlbumDataSource {
/// Return the album defined by [albumFile]
Future<Album> get(Account account, File albumFile);
// Create a new album
Future<Album> create(Account account, Album album);
/// Update an album
Future<void> update(Account account, Album album);
/// Clean up cached albums
///
/// Remove dangling albums in cache not listed in [albumFiles]. Do nothing if
/// this data source does not cache previous results
Future<void> cleanUp(Account account, List<File> albumFiles);
}
class AlbumRemoteDataSource implements AlbumDataSource {
@override
get(Account account, File albumFile) async {
_log.info("[get] ${albumFile.path}");
final fileRepo = FileRepo(FileWebdavDataSource());
final data = await GetFileBinary(fileRepo)(account, albumFile);
try {
return Album.fromJson(jsonDecode(utf8.decode(data)))
.copyWith(albumFile: albumFile);
} catch (e, stacktrace) {
dynamic d = data;
try {
d = utf8.decode(data);
} catch (_) {}
_log.severe("[get] Invalid json data: $d", e, stacktrace);
throw FormatException("Invalid album format");
}
}
@override
create(Account account, Album album) async {
_log.info("[create]");
final fileName = _makeAlbumFileName();
final filePath = "${getAlbumFileRoot(account)}/$fileName";
final fileRepo = FileRepo(FileWebdavDataSource());
try {
await PutFileBinary(fileRepo)(
account, filePath, utf8.encode(jsonEncode(album._toRemoteJson())));
} on ApiException catch (e) {
if (e.response.statusCode == 404) {
_log.info("[create] Missing album dir, creating");
// no dir
await _createDir(account);
// then retry
await PutFileBinary(fileRepo)(
account, filePath, utf8.encode(jsonEncode(album._toRemoteJson())));
} else {
rethrow;
}
}
// query album file
final list = await Ls(fileRepo)(account, File(path: filePath),
shouldExcludeRootDir: false);
return album.copyWith(albumFile: list.first);
}
@override
update(Account account, Album album) async {
_log.info("[update] ${album.albumFile.path}");
final fileRepo = FileRepo(FileWebdavDataSource());
await PutFileBinary(fileRepo)(account, album.albumFile.path,
utf8.encode(jsonEncode(album._toRemoteJson())));
}
@override
cleanUp(Account account, List<File> albumFiles) async {}
String _makeAlbumFileName() {
// just make up something
final timestamp = DateTime.now().millisecondsSinceEpoch;
final random = Random().nextInt(0xFFFFFF);
return "${timestamp.toRadixString(16)}-${random.toRadixString(16).padLeft(6, '0')}.json";
}
Future<void> _createDir(Account account) {
return Api(account).files().mkcol(path: getAlbumFileRoot(account));
}
static final _log = Logger("entity.album.AlbumRemoteDataSource");
}
class AlbumAppDbDataSource implements AlbumDataSource {
@override
get(Account account, File albumFile) {
_log.info("[get] ${albumFile.path}");
return AppDb.use((db) async {
final transaction = db.transaction(AppDb.albumStoreName, idbModeReadOnly);
final store = transaction.objectStore(AppDb.albumStoreName);
final Map result =
await store.getObject("${_getCacheKey(account, albumFile)}");
if (result != null) {
return Album.fromJson(result.cast<String, dynamic>());
} else {
throw CacheNotFoundException(
"No entry: ${_getCacheKey(account, albumFile)}");
}
});
}
@override
create(Account account, Album album) async {
_log.info("[create]");
throw UnimplementedError();
}
@override
update(Account account, Album album) {
_log.info("[update] ${album.albumFile.path}");
return AppDb.use((db) async {
final transaction =
db.transaction(AppDb.albumStoreName, idbModeReadWrite);
final store = transaction.objectStore(AppDb.albumStoreName);
await store.put(
album._toAppDbJson(), _getCacheKey(account, album.albumFile));
});
}
@override
cleanUp(Account account, List<File> albumFiles) async {}
static final _log = Logger("entity.album.AlbumAppDbDataSource");
}
class AlbumCachedDataSource implements AlbumDataSource {
@override
get(Account account, File albumFile) async {
try {
final cache = await _appDbSrc.get(account, albumFile);
if (cache.albumFile.etag?.isNotEmpty == true &&
cache.albumFile.etag == albumFile.etag) {
// cache is good
_log.fine("[get] etag matched for ${_getCacheKey(account, albumFile)}");
return cache;
} else {
_log.info(
"[get] Remote content updated for ${_getCacheKey(account, albumFile)}");
}
} catch (e, stacktrace) {
// no cache
if (e is! CacheNotFoundException) {
_log.severe("[get] Cache failure", e, stacktrace);
}
}
// no cache
final remote = await _remoteSrc.get(account, albumFile);
await _cacheResult(account, albumFile, remote);
return remote;
}
@override
update(Account account, Album album) async {
await _remoteSrc.update(account, album);
await _appDbSrc.update(account, album);
}
@override
create(Account account, Album album) => _remoteSrc.create(account, album);
@override
cleanUp(Account account, List<File> albumFiles) async {
AppDb.use((db) async {
final transaction =
db.transaction(AppDb.albumStoreName, idbModeReadWrite);
final store = transaction.objectStore(AppDb.albumStoreName);
final keyPrefix = _getCacheKeyPrefix(account);
final range = KeyRange.bound("$keyPrefix/", "$keyPrefix/\uffff");
final danglingKeys = await store
// get all albums for this account
.openKeyCursor(range: range, autoAdvance: true)
.map((cursor) => cursor.key)
// and pick the dangling ones
.where((key) =>
!albumFiles.any((f) => key == "${_getCacheKey(account, f)}"))
.toList();
for (final k in danglingKeys) {
_log.fine("[cleanUp] Removing DB entry: $k");
await store.delete(k);
}
});
}
Future<void> _cacheResult(Account account, File albumFile, Album result) {
return AppDb.use((db) async {
final transaction =
db.transaction(AppDb.albumStoreName, idbModeReadWrite);
final store = transaction.objectStore(AppDb.albumStoreName);
await store.put(result._toAppDbJson(), _getCacheKey(account, albumFile));
});
}
final _remoteSrc = AlbumRemoteDataSource();
final _appDbSrc = AlbumAppDbDataSource();
static final _log = Logger("entity.album.AlbumCachedDataSource");
}
String _getCacheKeyPrefix(Account account) => account.url;
String _getCacheKey(Account account, File albumFile) =>
"${_getCacheKeyPrefix(account)}/${albumFile.path}";

85
lib/entity/exif.dart Normal file
View file

@ -0,0 +1,85 @@
import 'dart:convert';
import 'package:exifdart/exifdart.dart';
import 'package:intl/intl.dart';
class Exif {
Exif(this.data);
dynamic operator [](String key) => data[key];
bool containsKey(String key) => data.containsKey(key);
Map<String, dynamic> toJson() {
return data.map((key, value) {
var jsonValue;
if (key == "MakerNote") {
jsonValue = base64UrlEncode(value);
} else if (value is Rational) {
jsonValue = value.toJson();
} else if (value is List<Rational>) {
jsonValue = value.map((e) => e.toJson()).toList();
} else {
jsonValue = value;
}
return MapEntry(key, jsonValue);
});
}
factory Exif.fromJson(Map<String, dynamic> json) {
return Exif(json.map((key, value) {
var exifValue;
if (key == "MakerNote") {
exifValue = base64Decode(value);
} else if (value is Map) {
exifValue = Rational.fromJson(value.cast<String, dynamic>());
} else if (value is List<Map>) {
exifValue = value
.map((e) => Rational.fromJson(e.cast<String, dynamic>()))
.toList();
} else {
exifValue = value;
}
return MapEntry(key, exifValue);
}));
}
@override
toString() {
final dataStr = data.entries.map((e) {
if (e.key == "MakerNote") {
return "${e.key}: '${base64UrlEncode(e.value)}'";
} else {
return "${e.key}: '${e.value}'";
}
}).join(", ");
return "$runtimeType {$dataStr}";
}
/// 0x010f Make
String get make => data["Make"];
/// 0x0110 Model
String get model => data["Model"];
/// 0x9003 DateTimeOriginal
DateTime get dateTimeOriginal => data.containsKey("DateTimeOriginal")
? dateTimeFormat.parse(data["DateTimeOriginal"])
: null;
/// 0x829a ExposureTime
Rational get exposureTime => data["ExposureTime"];
/// 0x829d FNumber
Rational get fNumber => data["FNumber"];
/// 0x8827 ISO/ISOSpeedRatings/PhotographicSensitivity
int get isoSpeedRatings => data["ISOSpeedRatings"];
/// 0x920a FocalLength
Rational get focalLength => data["FocalLength"];
static final dateTimeFormat = DateFormat("yyyy:MM:dd HH:mm:ss");
final Map<String, dynamic> data;
}

627
lib/entity/file.dart Normal file
View file

@ -0,0 +1,627 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:idb_sqflite/idb_sqflite.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/entity/exif.dart';
import 'package:nc_photos/entity/webdav_response_parser.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/string_extension.dart';
import 'package:path/path.dart' as path;
import 'package:xml/xml.dart';
int compareFileDateTimeDescending(File x, File y) {
final xDate = x.metadata?.exif?.dateTimeOriginal ?? x.lastModified;
final yDate = y.metadata?.exif?.dateTimeOriginal ?? y.lastModified;
final tmp = yDate.compareTo(xDate);
if (tmp != 0) {
return tmp;
} else {
// compare file name if files are modified at the same time
return x.path.compareTo(y.path);
}
}
/// Immutable object that hold metadata of a [File]
class Metadata {
Metadata({
DateTime lastUpdated,
this.fileEtag,
this.imageWidth,
this.imageHeight,
this.exif,
}) : this.lastUpdated = (lastUpdated ?? DateTime.now()).toUtc();
/// Parse Metadata from [json]
///
/// If the version saved in json does not match the active one, the
/// corresponding upgrader will be called one by one to upgrade the json,
/// version by version until it reached the active version. If any upgrader
/// in the chain is null, the upgrade process will fail
factory Metadata.fromJson(
Map<String, dynamic> json, {
MetadataUpgraderV1 upgraderV1,
}) {
final jsonVersion = json["version"];
if (jsonVersion < 2) {
json = upgraderV1?.call(json);
if (json == null) {
_log.info("[fromJson] Version $jsonVersion not compatible");
return null;
}
}
return Metadata(
lastUpdated: json["lastUpdated"] == null
? null
: DateTime.parse(json["lastUpdated"]),
fileEtag: json["fileEtag"],
imageWidth: json["imageWidth"],
imageHeight: json["imageHeight"],
exif: json["exif"] == null
? null
: Exif.fromJson(json["exif"].cast<String, dynamic>()),
);
}
Map<String, dynamic> toJson() {
return {
"version": version,
"lastUpdated": lastUpdated.toIso8601String(),
if (fileEtag != null) "fileEtag": fileEtag,
if (imageWidth != null) "imageWidth": imageWidth,
if (imageHeight != null) "imageHeight": imageHeight,
if (exif != null) "exif": exif.toJson(),
};
}
@override
toString() {
var product = "$runtimeType {"
"lastUpdated: $lastUpdated, ";
if (fileEtag != null) {
product += "fileEtag: $fileEtag, ";
}
if (imageWidth != null) {
product += "imageWidth: $imageWidth, ";
}
if (imageHeight != null) {
product += "imageHeight: $imageHeight, ";
}
if (exif != null) {
product += "exif: $exif, ";
}
return product + "}";
}
final DateTime lastUpdated;
/// Etag of the parent file when the metadata is saved
final String fileEtag;
final int imageWidth;
final int imageHeight;
final Exif exif;
/// versioning of this class, use to upgrade old persisted metadata
static const version = 2;
static final _log = Logger("entity.file.Metadata");
}
abstract class MetadataUpgrader {
Map<String, dynamic> call(Map<String, dynamic> json);
}
/// Upgrade v1 Metadata to v2
class MetadataUpgraderV1 implements MetadataUpgrader {
MetadataUpgraderV1({
@required this.fileContentType,
});
Map<String, dynamic> call(Map<String, dynamic> json) {
if (fileContentType == "image/webp") {
// Version 1 metadata for webp is bugged, drop it
return null;
} else {
return json;
}
}
final String fileContentType;
}
class File {
File({
@required String path,
this.contentLength,
this.contentType,
this.etag,
this.lastModified,
this.isCollection,
this.usedBytes,
this.hasPreview,
this.metadata,
}) : this.path = path.trimRightAny("/");
factory File.fromJson(Map<String, dynamic> json) {
return File(
path: json["path"],
contentLength: json["contentLength"],
contentType: json["contentType"],
etag: json["etag"],
lastModified: json["lastModified"] == null
? null
: DateTime.parse(json["lastModified"]),
isCollection: json["isCollection"],
usedBytes: json["usedBytes"],
hasPreview: json["hasPreview"],
metadata: json["metadata"] == null
? null
: Metadata.fromJson(
json["metadata"].cast<String, dynamic>(),
upgraderV1: MetadataUpgraderV1(
fileContentType: json["contentType"],
),
),
);
}
@override
toString() {
var product = "$runtimeType {"
"path: '$path', ";
if (contentLength != null) {
product += "contentLength: $contentLength, ";
}
if (contentType != null) {
product += "contentType: '$contentType', ";
}
if (etag != null) {
product += "etag: '$etag', ";
}
if (lastModified != null) {
product += "lastModified: $lastModified, ";
}
if (isCollection != null) {
product += "isCollection: $isCollection, ";
}
if (usedBytes != null) {
product += "usedBytes: $usedBytes, ";
}
if (hasPreview != null) {
product += "hasPreview: $hasPreview, ";
}
if (metadata != null) {
product += "metadata: $metadata, ";
}
return product + "}";
}
Map<String, dynamic> toJson() {
return {
"path": path,
if (contentLength != null) "contentLength": contentLength,
if (contentType != null) "contentType": contentType,
if (etag != null) "etag": etag,
if (lastModified != null) "lastModified": lastModified.toIso8601String(),
if (isCollection != null) "isCollection": isCollection,
if (usedBytes != null) "usedBytes": usedBytes,
if (hasPreview != null) "hasPreview": hasPreview,
if (metadata != null) "metadata": metadata.toJson(),
};
}
File copyWith({
String path,
int contentLength,
String contentType,
String etag,
DateTime lastModified,
bool isCollection,
int usedBytes,
bool hasPreview,
Metadata metadata,
}) {
return File(
path: path ?? this.path,
contentLength: contentLength ?? this.contentLength,
contentType: contentType ?? this.contentType,
etag: etag ?? this.etag,
lastModified: lastModified ?? this.lastModified,
isCollection: isCollection ?? this.isCollection,
usedBytes: usedBytes ?? this.usedBytes,
hasPreview: hasPreview ?? this.hasPreview,
metadata: metadata ?? this.metadata,
);
}
File withoutMetadata() {
return File(
path: path,
contentLength: contentLength,
contentType: contentType,
etag: etag,
lastModified: lastModified,
isCollection: isCollection,
usedBytes: usedBytes,
hasPreview: hasPreview,
);
}
/// Return the path of this file with the DAV part stripped
String get strippedPath {
// WebDAV path: remote.php/dav/files/{username}/{path}
if (path.contains("remote.php/dav/files")) {
return path
.substring(path.indexOf("/", "remote.php/dav/files/".length) + 1);
} else {
return path;
}
}
final String path;
final int contentLength;
final String contentType;
final String etag;
final DateTime lastModified;
final bool isCollection;
final int usedBytes;
final bool hasPreview;
// metadata
final Metadata metadata;
}
class FileRepo {
FileRepo(this.dataSrc);
/// See [FileDataSource.list]
Future<List<File>> list(Account account, File root) =>
this.dataSrc.list(account, root);
/// See [FileDataSource.remove]
Future<void> remove(Account account, File file) =>
this.dataSrc.remove(account, file);
/// See [FileDataSource.getBinary]
Future<Uint8List> getBinary(Account account, File file) =>
this.dataSrc.getBinary(account, file);
/// See [FileDataSource.putBinary]
Future<void> putBinary(Account account, String path, Uint8List content) =>
this.dataSrc.putBinary(account, path, content);
/// See [FileDataSource.updateMetadata]
Future<void> updateMetadata(Account account, File file, Metadata metadata) =>
this.dataSrc.updateMetadata(account, file, metadata);
final FileDataSource dataSrc;
}
abstract class FileDataSource {
/// List all files under [f]
Future<List<File>> list(Account account, File f);
/// Remove file
Future<void> remove(Account account, File f);
/// Read file as binary array
Future<Uint8List> getBinary(Account account, File f);
/// Upload content to [path]
Future<void> putBinary(Account account, String path, Uint8List content);
/// Update metadata for a file
///
/// This will completely replace the metadata of the file [f]. Partial update
/// is not supported
Future<void> updateMetadata(Account account, File f, Metadata metadata);
}
class FileWebdavDataSource implements FileDataSource {
@override
list(Account account, File f) async {
_log.fine("[list] ${f.path}");
final response = await Api(account).files().propfind(
path: f.path,
getlastmodified: 1,
resourcetype: 1,
getetag: 1,
getcontenttype: 1,
getcontentlength: 1,
hasPreview: 1,
customNamespaces: {
"com.nkming.nc_photos": "app",
},
customProperties: [
"app:metadata",
],
);
if (!response.isGood) {
_log.severe("[list] Failed requesting server: $response");
throw ApiException(
response: response,
message: "Failed communicating with server: ${response.statusCode}");
}
final xml = XmlDocument.parse(response.body);
final files = WebdavFileParser()(xml);
// _log.fine("[list] Parsed files: [$files]");
return files.map((e) {
if (e.metadata == null || e.metadata.fileEtag == e.etag) {
return e;
} else {
_log.info("[list] Ignore outdated metadata for ${e.path}");
return e.withoutMetadata();
}
}).toList();
}
@override
remove(Account account, File f) async {
_log.info("[remove] ${f.path}");
final response = await Api(account).files().delete(path: f.path);
if (!response.isGood) {
_log.severe("[remove] Failed requesting server: $response");
throw ApiException(
response: response,
message: "Failed communicating with server: ${response.statusCode}");
}
}
@override
getBinary(Account account, File f) async {
_log.info("[getBinary] ${f.path}");
final response = await Api(account).files().get(path: f.path);
if (!response.isGood) {
_log.severe("[getBinary] Failed requesting server: $response");
throw ApiException(
response: response,
message: "Failed communicating with server: ${response.statusCode}");
}
return response.body;
}
@override
putBinary(Account account, String path, Uint8List content) async {
_log.info("[putBinary] $path");
final response =
await Api(account).files().put(path: path, content: content);
if (!response.isGood) {
_log.severe("[putBinary] Failed requesting server: $response");
throw ApiException(
response: response,
message: "Failed communicating with server: ${response.statusCode}");
}
}
@override
updateMetadata(Account account, File f, Metadata metadata) async {
_log.info("[updateMetadata] ${f.path}");
if (metadata != null && metadata.fileEtag != f.etag) {
_log.warning(
"[updateMetadata] etag mismatch (metadata: ${metadata.fileEtag}, file: ${f.etag})");
}
final setProps = {
if (metadata != null) "app:metadata": jsonEncode(metadata.toJson()),
};
final removeProps = [
if (metadata == null) "app:metadata",
];
final response = await Api(account).files().proppatch(
path: f.path,
namespaces: {
"com.nkming.nc_photos": "app",
},
set: setProps.isNotEmpty ? setProps : null,
remove: removeProps.isNotEmpty ? removeProps : null,
);
if (!response.isGood) {
_log.severe("[updateMetadata] Failed requesting server: $response");
throw ApiException(
response: response,
message: "Failed communicating with server: ${response.statusCode}");
}
}
static final _log = Logger("entity.file.FileWebdavDataSource");
}
class FileAppDbDataSource implements FileDataSource {
@override
list(Account account, File f) {
_log.info("[list] ${f.path}");
return AppDb.use((db) async {
final transaction = db.transaction(AppDb.fileStoreName, idbModeReadOnly);
final store = transaction.objectStore(AppDb.fileStoreName);
return await _doList(store, account, f);
});
}
@override
remove(Account account, File f) {
_log.info("[remove] ${f.path}");
return AppDb.use((db) async {
final transaction = db.transaction(AppDb.fileStoreName, idbModeReadWrite);
final store = transaction.objectStore(AppDb.fileStoreName);
// we don't yet support removing dirs
// final range = KeyRange.bound(f.path, f.path + "\uffff");
await store.delete(_getCacheKey(account, f));
});
}
@override
getBinary(Account account, File f) {
_log.info("[getBinary] ${f.path}");
throw UnimplementedError();
}
@override
putBinary(Account account, String path, Uint8List content) async {
_log.info("[putBinary] $path");
// do nothing, we currently don't store file contents locally
}
@override
updateMetadata(Account account, File f, Metadata metadata) {
_log.info("[updateMetadata] ${f.path}");
return AppDb.use((db) async {
final transaction = db.transaction(AppDb.fileStoreName, idbModeReadWrite);
final store = transaction.objectStore(AppDb.fileStoreName);
final parentDir = File(path: path.dirname(f.path));
final parentList = await _doList(store, account, parentDir);
final jsonList = parentList.map((e) {
if (e.path == f.path) {
return e.copyWith(metadata: metadata).toJson();
} else {
return e.toJson();
}
}).toList();
await store.put(jsonList, _getCacheKey(account, parentDir));
});
}
Future<List<File>> _doList(ObjectStore store, Account account, File f) async {
final List result = await store.getObject("${_getCacheKey(account, f)}");
if (result != null) {
return result
.cast<Map<dynamic, dynamic>>()
.map((e) => File.fromJson(e.cast()))
.toList();
} else {
throw CacheNotFoundException("No entry: ${_getCacheKey(account, f)}");
}
}
static final _log = Logger("entity.file.FileAppDbDataSource");
}
class FileCachedDataSource implements FileDataSource {
@override
list(Account account, File f) async {
final trimmedRootPath = f.path.trimAny("/");
List<File> cache;
try {
cache = await _appDbSrc.list(account, f);
// compare the cached root
final cacheRoot = cache.firstWhere(
(element) => element.path.trimAny("/") == trimmedRootPath,
orElse: () => null);
if (cacheRoot?.etag?.isNotEmpty == true && cacheRoot.etag == f.etag) {
// cache is good
_log.fine("[list] etag matched for ${_getCacheKey(account, f)}");
return cache;
} else {
_log.info(
"[list] Remote content updated for ${_getCacheKey(account, f)}");
}
} catch (e, stacktrace) {
// no cache
if (e is! CacheNotFoundException) {
_log.severe("[list] Cache failure", e, stacktrace);
}
}
// no cache
try {
final remote = await _remoteSrc.list(account, f);
await _cacheResult(account, f, remote);
if (cache != null) {
try {
await _cleanUpCachedList(account, remote, cache);
} catch (e, stacktrace) {
_log.severe("[list] Failed while _cleanUpCachedList", e, stacktrace);
// ignore error
}
}
return remote;
} on ApiException catch (e) {
if (e.response.statusCode == 404) {
_log.info("[list] File removed: $f");
_appDbSrc.remove(account, f);
return [];
} else {
rethrow;
}
}
}
@override
remove(Account account, File f) async {
await _appDbSrc.remove(account, f);
await _remoteSrc.remove(account, f);
}
@override
getBinary(Account account, File f) {
return _remoteSrc.getBinary(account, f);
}
@override
putBinary(Account account, String path, Uint8List content) async {
await _remoteSrc.putBinary(account, path, content);
}
@override
updateMetadata(Account account, File f, Metadata metadata) async {
await _remoteSrc
.updateMetadata(account, f, metadata)
.then((_) => _appDbSrc.updateMetadata(account, f, metadata));
}
Future<void> _cacheResult(Account account, File f, List<File> result) {
return AppDb.use((db) async {
final transaction = db.transaction(AppDb.fileStoreName, idbModeReadWrite);
final store = transaction.objectStore(AppDb.fileStoreName);
await store.put(
result.map((e) => e.toJson()).toList(), _getCacheKey(account, f));
});
}
Future<void> _cleanUpCachedList(
Account account, List<File> remoteResults, List<File> cachedResults) {
final removed = cachedResults
.where((cache) =>
!remoteResults.any((remote) => remote.path == cache.path))
.toList();
if (removed.isEmpty) {
return Future.delayed(Duration.zero);
}
_log.info(
"[_cleanUpCachedList] Removing cache: ${removed.toReadableString()}");
return AppDb.use((db) async {
final transaction = db.transaction(AppDb.fileStoreName, idbModeReadWrite);
final store = transaction.objectStore(AppDb.fileStoreName);
for (final r in removed) {
final key = _getCacheKey(account, r);
_log.fine("[_cleanUpCachedList] Removing DB entry: $key");
// delete the dir itself
await store.delete(key);
// then its children
final range = KeyRange.bound("$key/", "$key/\uffff");
// delete with KeyRange is not supported in idb_shim/idb_sqflite
// await store.delete(range);
final keys = await store
.openKeyCursor(range: range, autoAdvance: true)
.map((cursor) => cursor.key)
.toList();
for (final k in keys) {
_log.fine("[_cleanUpCachedList] Removing DB entry: $k");
await store.delete(k);
}
}
});
}
final _remoteSrc = FileWebdavDataSource();
final _appDbSrc = FileAppDbDataSource();
static final _log = Logger("entity.file.FileCachedDataSource");
}
String _getCacheKey(Account account, File file) =>
"${account.url}/${file.path}";

10
lib/entity/file_util.dart Normal file
View file

@ -0,0 +1,10 @@
import 'package:nc_photos/entity/file.dart';
bool isSupportedFormat(File file) =>
_supportedFormatMimes.contains(file.contentType);
const _supportedFormatMimes = [
"image/jpeg",
"image/png",
"image/webp",
];

View file

@ -0,0 +1,228 @@
import 'dart:convert';
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/string_extension.dart';
import 'package:xml/xml.dart';
class WebdavFileParser {
List<File> call(XmlDocument xml) {
_namespaces = _parseNamespaces(xml);
final body = () {
try {
return xml.children.whereType<XmlElement>().firstWhere((element) =>
element.matchQualifiedName("multistatus",
prefix: "DAV:", namespaces: _namespaces));
} catch (_) {
_log.severe("[call] Missing element: multistatus");
rethrow;
}
}();
return body.children
.whereType<XmlElement>()
.where((element) => element.matchQualifiedName("response",
prefix: "DAV:", namespaces: _namespaces))
.map((element) {
try {
return _toFile(element);
} catch (e, stacktrace) {
_log.severe("[call] Failed parsing XML", e, stacktrace);
return null;
}
})
.where((element) => element != null)
.toList();
}
Map<String, String> get namespaces => _namespaces;
Map<String, String> _parseNamespaces(XmlDocument xml) {
final namespaces = <String, String>{};
final xmlContent = xml.descendants.whereType<XmlElement>().firstWhere(
(element) => !element.name.qualified.startsWith("?"),
orElse: () => XmlElement(XmlName.fromString("")));
for (final a in xmlContent.attributes) {
if (a.name.prefix == "xmlns") {
namespaces[a.name.local] = a.value;
} else if (a.name.local == "xmlns") {
namespaces["!"] = a.value;
}
}
// _log.fine("[_parseNamespaces] Namespaces: $namespaces");
return namespaces;
}
/// Map <DAV:response> contents to File
File _toFile(XmlElement element) {
String path;
int contentLength;
String contentType;
String etag;
DateTime lastModified;
bool isCollection;
int usedBytes;
bool hasPreview;
Metadata metadata;
for (final child in element.children.whereType<XmlElement>()) {
if (child.matchQualifiedName("href",
prefix: "DAV:", namespaces: _namespaces)) {
path = Uri.decodeComponent(child.innerText).trimLeftAny("/");
} else if (child.matchQualifiedName("propstat",
prefix: "DAV:", namespaces: _namespaces)) {
final status = child.children
.whereType<XmlElement>()
.firstWhere((element) => element.matchQualifiedName("status",
prefix: "DAV:", namespaces: _namespaces))
.innerText;
if (!status.contains(" 200 ")) {
continue;
}
final prop = child.children.whereType<XmlElement>().firstWhere(
(element) => element.matchQualifiedName("prop",
prefix: "DAV:", namespaces: _namespaces));
final propParser = _PropParser(namespaces: _namespaces);
propParser.parse(prop);
contentLength = propParser.contentLength;
contentType = propParser.contentType;
etag = propParser.etag;
lastModified = propParser.lastModified;
isCollection = propParser.isCollection;
usedBytes = propParser.usedBytes;
hasPreview = propParser.hasPreview;
metadata = propParser.metadata;
}
}
return File(
path: path,
contentLength: contentLength,
contentType: contentType,
etag: etag,
lastModified: lastModified,
isCollection: isCollection,
usedBytes: usedBytes,
hasPreview: hasPreview,
metadata: metadata,
);
}
MapEntry<XmlName, dynamic> _xmlElementToMapEntry(XmlElement element) {
final key = element.name;
try {
final textNode = element.children
.where((node) => node is XmlText && node.text.trim().isNotEmpty)
.first;
return MapEntry(key, textNode.text);
} on StateError {
// No text
final value = <XmlName, dynamic>{};
for (final e in element.children.whereType<XmlElement>()) {
final entry = _xmlElementToMapEntry(e);
value[entry.key] = entry.value;
}
return MapEntry(key, value);
}
}
var _namespaces = <String, String>{};
static final _log =
Logger("entity.webdav_response_parser.WebdavResponseParser");
}
class _PropParser {
_PropParser({this.namespaces = const {}});
/// Parse <DAV:prop> element contents
void parse(XmlElement element) {
for (final child in element.children.whereType<XmlElement>()) {
if (child.matchQualifiedName("getlastmodified",
prefix: "DAV:", namespaces: namespaces)) {
_lastModified = HttpDate.parse(child.innerText);
} else if (child.matchQualifiedName("getcontentlength",
prefix: "DAV:", namespaces: namespaces)) {
_contentLength = int.parse(child.innerText);
} else if (child.matchQualifiedName("getcontenttype",
prefix: "DAV:", namespaces: namespaces)) {
_contentType = child.innerText;
} else if (child.matchQualifiedName("getetag",
prefix: "DAV:", namespaces: namespaces)) {
_etag = child.innerText.replaceAll("\"", "");
} else if (child.matchQualifiedName("quota-used-bytes",
prefix: "DAV:", namespaces: namespaces)) {
_usedBytes = int.parse(child.innerText);
} else if (child.matchQualifiedName("resourcetype",
prefix: "DAV:", namespaces: namespaces)) {
_isCollection = child.children.whereType<XmlElement>().any((element) =>
element.matchQualifiedName("collection",
prefix: "DAV:", namespaces: namespaces));
} else if (child.matchQualifiedName("has-preview",
prefix: "http://nextcloud.org/ns", namespaces: namespaces)) {
_hasPreview = child.innerText == "true";
}
}
// 2nd pass that depends on data in 1st pass
for (final child in element.children.whereType<XmlElement>()) {
if (child.matchQualifiedName("metadata",
prefix: "com.nkming.nc_photos", namespaces: namespaces)) {
_metadata = Metadata.fromJson(
jsonDecode(child.innerText),
upgraderV1: MetadataUpgraderV1(
fileContentType: _contentType,
),
);
}
}
}
DateTime get lastModified => _lastModified;
int get contentLength => _contentLength;
String get contentType => _contentType;
String get etag => _etag;
int get usedBytes => _usedBytes;
bool get isCollection => _isCollection;
bool get hasPreview => _hasPreview;
Metadata get metadata => _metadata;
final Map<String, String> namespaces;
DateTime _lastModified;
int _contentLength;
String _contentType;
String _etag;
int _usedBytes;
bool _isCollection;
bool _hasPreview;
Metadata _metadata;
}
extension on XmlElement {
bool matchQualifiedName(
String local, {
String prefix,
Map<String, String> namespaces,
}) {
final localNamespaces = <String, String>{};
for (final a in attributes) {
if (a.name.prefix == "xmlns") {
localNamespaces[a.name.local] = a.value;
} else if (a.name.local == "xmlns") {
localNamespaces["!"] = a.value;
}
}
return name.local == local &&
(name.prefix == prefix ||
// match default namespace
(name.prefix == null && namespaces["!"] == prefix) ||
// match global namespace
namespaces.entries
.where((element2) => element2.value == prefix)
.any((element) => element.key == name.prefix) ||
// match local namespace
localNamespaces.entries
.where((element2) => element2.value == prefix)
.any((element) => element.key == name.prefix));
}
}

63
lib/event/event.dart Normal file
View file

@ -0,0 +1,63 @@
import 'dart:async';
import 'package:event_bus/event_bus.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
class AppEventListener<T> {
AppEventListener(this._listener);
void begin() {
if (_subscription != null) {
_log.warning("[beginListenEvent] Already listening");
return;
}
_subscription = _stream.listen(_listener);
}
void end() {
if (_subscription == null) {
_log.warning("[endListenEvent] Already not listening");
return;
}
_subscription.cancel();
_subscription = null;
}
final void Function(T) _listener;
final _stream = KiwiContainer().resolve<EventBus>().on<T>();
StreamSubscription<T> _subscription;
final _log = Logger("event.event.AppEventListener<${T.runtimeType}>");
}
class AlbumCreatedEvent {
AlbumCreatedEvent(this.account, this.album);
final Account account;
final Album album;
}
class AlbumUpdatedEvent {
AlbumUpdatedEvent(this.account, this.album);
final Account account;
final Album album;
}
class FileMetadataUpdatedEvent {
FileMetadataUpdatedEvent(this.account, this.file);
final Account account;
final File file;
}
class FileRemovedEvent {
FileRemovedEvent(this.account, this.file);
final Account account;
final File file;
}

47
lib/exception.dart Normal file
View file

@ -0,0 +1,47 @@
import 'package:nc_photos/api/api.dart';
class CacheNotFoundException implements Exception {
CacheNotFoundException([this.message]);
@override
toString() {
if (message == null) {
return "CacheNotFoundException";
} else {
return "CacheNotFoundException: $message";
}
}
final dynamic message;
}
class ApiException implements Exception {
ApiException({this.response, this.message});
@override
toString() {
if (message == null) {
return "ApiException";
} else {
return "ApiException: $message";
}
}
final Response response;
final dynamic message;
}
class PermissionException implements Exception {
PermissionException([this.message]);
@override
toString() {
if (message == null) {
return "PermissionException";
} else {
return "PermissionException: $message";
}
}
final dynamic message;
}

19
lib/exception_util.dart Normal file
View file

@ -0,0 +1,19 @@
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:nc_photos/exception.dart';
/// Convert an exception to a user-facing string
///
/// Typically used with SnackBar to show a proper error message
String toUserString(dynamic exception, BuildContext context) {
if (exception is ApiException) {
if (exception.response.statusCode == 401) {
return AppLocalizations.of(context).errorUnauthenticated;
}
} else if (exception is SocketException) {
return AppLocalizations.of(context).errorDisconnected;
}
return exception.toString();
}

View file

@ -0,0 +1,38 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:image_size_getter/image_size_getter.dart';
class AsyncMemoryInput extends AsyncImageInput {
final Uint8List bytes;
const AsyncMemoryInput(this.bytes);
factory AsyncMemoryInput.byteBuffer(ByteBuffer buffer) =>
AsyncMemoryInput(buffer.asUint8List());
@override
getRange(int start, int end) async => bytes.sublist(start, end);
@override
get length async => bytes.length;
@override
exists() async => bytes.isNotEmpty;
}
class AsyncFileInput extends AsyncImageInput {
final File file;
AsyncFileInput(this.file);
@override
getRange(int start, int end) => file
.openRead(start, end)
.reduce((previous, element) => previous + element);
@override
get length => file.length();
@override
exists() => file.exists();
}

View file

@ -0,0 +1,15 @@
extension IterableExtension<T> on Iterable<T> {
/// Return a new sorted list
List<T> sorted([int compare(T a, T b)]) => this.toList()..sort(compare);
/// Return a string representation of this iterable by joining the result of
/// toString for each items
String toReadableString() => "[${join(', ')}]";
Iterable<U> mapWithIndex<U>(U fn(int index, T element)) sync* {
int i = 0;
for (final e in this) {
yield fn(i++, e);
}
}
}

17
lib/k.dart Normal file
View file

@ -0,0 +1,17 @@
/// Version string shown in settings page
const version = "4.0-f6b400";
/// Show a snack bar for a short amount of time
const snackBarDurationShort = const Duration(seconds: 4);
/// Show a snack bar for a normal amount of time
const snackBarDurationNormal = const Duration(seconds: 7);
/// Duration for short animation
const animationDurationShort = const Duration(milliseconds: 100);
/// Duration for normal animation
const animationDurationNormal = const Duration(milliseconds: 250);
/// Duration for long animation
const animationDurationLong = const Duration(milliseconds: 500);

351
lib/l10n/app_en.arb Normal file
View file

@ -0,0 +1,351 @@
{
"appTitle": "Photos",
"translator": "",
"@translator": {
"description": "Name of the translator(s) for this language"
},
"photosTabLabel": "Photos",
"@photosTabLabel": {
"description": "Label of the tab that lists user photos"
},
"albumsTabLabel": "Albums",
"@albumsTabLabel": {
"description": "Label of the tab that lists user albums"
},
"zoomTooltip": "Zoom",
"@zoomTooltip": {
"description": "Tooltip of the zoom button"
},
"settingsMenuLabel": "Settings",
"@settingsMenuLabel": {
"description": "Label of the settings entry in menu"
},
"selectionAppBarTitle": "{count} selected",
"@selectionAppBarTitle": {
"description": "Title of the contextual app bar that shows number of selected items",
"placeholders": {
"count": {
"example": "1"
}
}
},
"addSelectedToAlbumTooltip": "Add selected to album",
"@addSelectedToAlbumTooltip": {
"description": "Tooltip of the button that adds selected items to an album"
},
"addSelectedToAlbumSuccessNotification": "All items added to {album} successfully",
"@addSelectedToAlbumSuccessNotification": {
"description": "Inform user that the selected items are added to an album successfully",
"placeholders": {
"album": {
"example": "Sunday Walk"
}
}
},
"addSelectedToAlbumFailureNotification": "Failed adding items to album",
"@addSelectedToAlbumFailureNotification": {
"description": "Inform user that the selected items cannot be added to an album"
},
"addToAlbumTooltip": "Add to album",
"@addToAlbumTooltip": {
"description": "Tooltip for the add to album button"
},
"addToAlbumSuccessNotification": "Added to {album} successfully",
"@addToAlbumSuccessNotification": {
"description": "Inform user that the item is added to an album successfully",
"placeholders": {
"album": {
"example": "Sunday Walk"
}
}
},
"addToAlbumFailureNotification": "Failed adding to album",
"@addToAlbumFailureNotification": {
"description": "Inform user that the item cannot be added to an album"
},
"deleteSelectedTooltip": "Delete selected",
"@deleteSelectedTooltip": {
"description": "Tooltip of the button that deletes selected items"
},
"deleteSelectedProcessingNotification": "{count, plural, =1{Deleting 1 item} other{Deleting {count} items}}",
"@deleteSelectedProcessingNotification": {
"description": "Inform user that the selected items are being deleted",
"placeholders": {
"count": {
"example": "1"
}
}
},
"deleteSelectedSuccessNotification": "All items deleted successfully",
"@deleteSelectedSuccessNotification": {
"description": "Inform user that the selected items are deleted successfully"
},
"deleteSelectedFailureNotification": "{count, plural, =1{Failed deleting 1 item} other{Failed deleting {count} items}}",
"@deleteSelectedFailureNotification": {
"description": "Inform user that some/all the selected items cannot be deleted",
"placeholders": {
"count": {
"example": "1"
}
}
},
"deleteTooltip": "Delete",
"@deleteTooltip": {
"description": "Tooltip for the delete button"
},
"deleteProcessingNotification": "Deleting item",
"@deleteProcessingNotification": {
"description": "Inform user that the item is being deleted"
},
"deleteSuccessNotification": "Deleted item successfully",
"@deleteSuccessNotification": {
"description": "Inform user that the item is deleted successfully"
},
"deleteFailureNotification": "Failed deleting item",
"@deleteFailureNotification": {
"description": "Inform user that the item cannot be deleted"
},
"removeSelectedFromAlbumTooltip": "Remove selected from album",
"@removeSelectedFromAlbumTooltip": {
"description": "Tooltip of the button that remove selected items from an album"
},
"removeSelectedFromAlbumSuccessNotification": "{count, plural, =1{1 item removed from album} other{{count} items removed from album}}",
"@removeSelectedFromAlbumSuccessNotification": {
"description": "Inform user that the selected items are removed from an album successfully",
"placeholders": {
"count": {
"example": "1"
}
}
},
"removeSelectedFromAlbumFailureNotification": "Failed removing items from album",
"@removeSelectedFromAlbumFailureNotification": {
"description": "Inform user that the selected items cannot be removed from an album"
},
"addServerTooltip": "Add server",
"@addServerTooltip": {
"description": "Tooltip of the button that adds a new server"
},
"removeServerSuccessNotification": "Removed {server} successfully",
"@removeServerSuccessNotification": {
"description": "Inform user that a server is removed successfully",
"placeholders": {
"server": {
"example": "http://www.example.com"
}
}
},
"createAlbumTooltip": "Create new album",
"@createAlbumTooltip": {
"description": "Tooltip of the button that creates a new album"
},
"createAlbumFailureNotification": "Failed creating album",
"@createAlbumFailureNotification": {
"description": "Inform user that an album cannot be created"
},
"albumSize": "{count, plural, =0{Empty} =1{1 item} other{{count} items}}",
"@albumSize": {
"description": "Number of items inside an album",
"placeholders": {
"count": {
"example": "1"
}
}
},
"connectingToServer": "Connecting to\n{server}",
"@connectingToServer": {
"description": "Inform user that the app is connecting to a server",
"placeholders": {
"server": {
"example": "http://www.example.com"
}
}
},
"nameInputHint": "Name",
"@nameInputHint": {
"description": "Hint of the text field expecting name data"
},
"albumNameInputInvalidEmpty": "Please enter the album name",
"@albumNameInputInvalidEmpty": {
"description": "Inform user that the album name input field cannot be empty"
},
"skipButtonLabel": "SKIP",
"@skipButtonLabel": {
"description": "Label of the skip button"
},
"confirmButtonLabel": "CONFIRM",
"@confirmButtonLabel": {
"description": "Label of the confirm button"
},
"signInHeaderText": "Sign in to Nextcloud server",
"@signInHeaderText": {
"description": "Inform user what to do in sign in widget"
},
"serverAddressInputHint": "Sever address",
"@serverAddressInputHint": {
"description": "Hint of the text field expecting server address data"
},
"serverAddressInputInvalidEmpty": "Please enter the server address",
"@serverAddressInputInvalidEmpty": {
"description": "Inform user that the server address input field cannot be empty"
},
"usernameInputHint": "Username",
"@usernameInputHint": {
"description": "Hint of the text field expecting username data"
},
"usernameInputInvalidEmpty": "Please enter your username",
"@usernameInputInvalidEmpty": {
"description": "Inform user that the username input field cannot be empty"
},
"passwordInputHint": "Password",
"@passwordInputHint": {
"description": "Hint of the text field expecting password data"
},
"passwordInputInvalidEmpty": "Please enter your password",
"@passwordInputInvalidEmpty": {
"description": "Inform user that the password input field cannot be empty"
},
"rootPickerHeaderText": "Pick the folders to be included",
"@rootPickerHeaderText": {
"description": "Inform user what to do in root picker widget"
},
"rootPickerSubHeaderText": "Only photos inside the folders will be shown. Press Skip to include all",
"@rootPickerSubHeaderText": {
"description": "Inform user what to do in root picker widget"
},
"rootPickerNavigateUpItemText": "(go back)",
"@rootPickerNavigateUpItemText": {
"description": "Text of the list item to navigate up the directory tree"
},
"setupWidgetTitle": "Getting started",
"@setupWidgetTitle": {
"description": "Title of the introductory widget"
},
"setupSettingsModifyLaterHint": "You can change this later in Settings",
"@setupSettingsModifyLaterHint": {
"description": "Inform user that they can modify this setting after the setup process"
},
"setupHiddenPrefDirNoticeDetail": "This app creates a folder on the Nextcloud server to store preference files. Please do not modify or remove it unless you plan to remove this app",
"@setupHiddenPrefDirNoticeDetail": {
"description": "Inform user about the preference folder created by this app"
},
"settingsWidgetTitle": "Settings",
"@settingsWidgetTitle": {
"description": "Title of the Settings widget"
},
"settingsExifSupportTitle": "EXIF support",
"@settingsExifSupportTitle": {
"description": "Title of the EXIF support setting"
},
"settingsExifSupportTrueSubtitle": "Require extra network usage",
"@settingsExifSupportTrueSubtitle": {
"description": "Subtitle of the EXIF support setting when the value is true. The goal is to warn user about the possible side effects of enabling this setting"
},
"settingsAboutSectionTitle": "About",
"@settingsAboutSectionTitle": {
"description": "Title of the about section in settings widget"
},
"settingsVersionTitle": "Version",
"@settingsVersionTitle": {
"description": "Title of the version data item"
},
"settingsSourceCodeTitle": "Source code",
"@settingsSourceCodeTitle": {
"description": "Title of the source code item"
},
"settingsTranslatorTitle": "Translator",
"@settingsTranslatorTitle": {
"description": "Title of the translator item"
},
"writePreferenceFailureNotification": "Failed setting preference",
"@writePreferenceFailureNotification": {
"description": "Inform user that the preference file cannot be modified"
},
"enableButtonLabel": "ENABLE",
"@enableButtonLabel": {
"description": "Label of the enable button"
},
"exifSupportDetails": "Enabling EXIF support will make various metadata available like date taken, camera model, etc. In order to read these metadata, extra network usage is required to download the original full-sized image. The app will only start downloading when connected to a Wi-Fi network",
"@exifSupportDetails": {
"description": "Detailed description of the exif support feature"
},
"exifSupportConfirmationDialogTitle": "Enable EXIF support?",
"@exifSupportConfirmationDialogTitle": {
"description": "Title of the dialog to confirm enabling exif support"
},
"doneButtonLabel": "DONE",
"@doneButtonLabel": {
"description": "Label of the done button"
},
"nextButtonLabel": "NEXT",
"@nextButtonLabel": {
"description": "Label of the next button"
},
"connectButtonLabel": "CONNECT",
"@connectButtonLabel": {
"description": "Label of the connect button"
},
"rootPickerSkipConfirmationDialogContent": "All your files will be included",
"@rootPickerSkipConfirmationDialogContent": {
"description": "Inform user what happens after skipping root picker"
},
"megapixelCount": "{count}MP",
"@megapixelCount": {
"description": "Resolution of an image in megapixel",
"placeholders": {
"count": {
"example": "1.3"
}
}
},
"secondCountSymbol": "{count}s",
"@secondCountSymbol": {
"description": "Number of seconds",
"placeholders": {
"count": {
"example": "1"
}
}
},
"millimeterCountSymbol": "{count}mm",
"@millimeterCountSymbol": {
"description": "Number of millimeters",
"placeholders": {
"count": {
"example": "1"
}
}
},
"detailsTooltip": "Details",
"@detailsTooltip": {
"description": "Tooltip of the details button"
},
"downloadTooltip": "Download",
"@downloadTooltip": {
"description": "Tooltip of the download button"
},
"downloadProcessingNotification": "Downloading file",
"@downloadProcessingNotification": {
"description": "Inform user that the file is being downloaded"
},
"downloadSuccessNotification": "Downloaded file successfully",
"@downloadSuccessNotification": {
"description": "Inform user that the file is downloaded successfully"
},
"downloadFailureNotification": "Failed downloading file",
"@downloadFailureNotification": {
"description": "Inform user that the file cannot be downloaded"
},
"downloadFailureNoPermissionNotification": "Require storage access permission",
"@downloadFailureNoPermissionNotification": {
"description": "Inform user that the file cannot be downloaded due to missing storage permission"
},
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
"@errorUnauthenticated": {
"description": "Error message when server responds with HTTP401"
},
"errorDisconnected": "Unable to connect. Server may be offline or your device may be disconnected",
"@errorDisconnected": {
"description": "Error message when the app can't connect to the server"
}
}

9
lib/list_extension.dart Normal file
View file

@ -0,0 +1,9 @@
extension ListExtension<T> on List<T> {
/// Return a new list with only distinct elements
List<T> distinct() {
final s = Set();
return this.where((element) => s.add(element)).toList();
}
Iterable<T> takeIndex(List<int> indexes) => indexes.map((e) => this[e]);
}

78
lib/main.dart Normal file
View file

@ -0,0 +1,78 @@
import 'package:equatable/equatable.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/metadata_task_manager.dart';
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
import 'package:nc_photos/pref.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
_initLog();
await _initPref();
_initBloc();
_initKiwi();
_initEquatable();
runApp(platform.MyApp());
}
void _initLog() {
if (kDebugMode) {
debugPrintGestureArenaDiagnostics = true;
}
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
// dev.log(
// "${record.level.name} ${record.time}: ${record.message}",
// time: record.time,
// sequenceNumber: record.sequenceNumber,
// level: record.level.value,
// name: record.loggerName,
// );
if (kReleaseMode && record.level <= Level.FINE) {
return;
}
String msg =
"[${record.loggerName}] ${record.level.name} ${record.time}: ${record.message}";
if (record.error != null) {
msg += " (throw: ${record.error.runtimeType} { ${record.error} })";
}
if (record.stackTrace != null) {
msg += "\nStack Trace:\n${record.stackTrace}";
}
debugPrint(msg);
});
}
Future<void> _initPref() => Pref.init();
void _initBloc() {
Bloc.observer = _BlocObserver();
}
void _initKiwi() {
final kiwi = KiwiContainer();
kiwi.registerInstance<EventBus>(EventBus());
kiwi.registerInstance<MetadataTaskManager>(MetadataTaskManager());
}
void _initEquatable() {
EquatableConfig.stringify = false;
}
class _BlocObserver extends BlocObserver {
@override
onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
_log.finer("${bloc.runtimeType} $change");
}
static final _log = Logger("_BlocObserver");
}

View file

@ -0,0 +1,67 @@
import 'dart:async';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/use_case/update_missing_metadata.dart';
/// Task to update metadata for missing files
class MetadataTask {
MetadataTask(this.account);
@override
toString() {
return "$runtimeType {"
"account: $account, "
"}";
}
Future<void> call() async {
final fileRepo = FileRepo(FileCachedDataSource());
for (final r in account.roots) {
final op = UpdateMissingMetadata(fileRepo);
await for (final _ in op(account,
File(path: "${api_util.getWebdavRootUrlRelative(account)}/$r"))) {
if (!Pref.inst().isEnableExif()) {
_log.info("[call] EXIF disabled, task ending immaturely");
op.stop();
return;
}
}
}
}
final Account account;
static final _log = Logger("metadata_task_manager.MetadataTask");
}
/// Manage metadata tasks to run concurrently
class MetadataTaskManager {
MetadataTaskManager() {
_handleStream();
}
/// Add a task to the queue
void addTask(MetadataTask task) {
_log.info("[addTask] New task added: $task");
_streamController.add(task);
}
void _handleStream() async {
await for (final task in _streamController.stream) {
if (Pref.inst().isEnableExif()) {
_log.info("[_doTask] Executing task: $task");
await task();
} else {
_log.info("[_doTask] Ignoring task: $task");
}
}
}
final _streamController = StreamController<MetadataTask>.broadcast();
static final _log = Logger("metadata_task_manager.MetadataTaskManager");
}

View file

@ -0,0 +1,18 @@
import 'dart:typed_data';
import 'package:flutter/services.dart';
class MediaStore {
static const exceptionCodePermissionError = "permissionError";
static Future<void> saveFileToDownload(
String fileName, Uint8List fileContent) async {
await _channel.invokeMethod("saveFileToDownload", <String, dynamic>{
"fileName": fileName,
"content": fileContent,
});
}
static const _channel =
const MethodChannel("com.nkming.nc_photos/media_store");
}

View file

@ -0,0 +1,35 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/mobile/android/media_store.dart';
import 'package:nc_photos/platform/downloader.dart' as itf;
import 'package:path/path.dart' as path;
class Downloader extends itf.Downloader {
@override
downloadFile(Account account, File file) {
if (Platform.isAndroid) {
return _downloadFileAndroid(account, file);
} else {
throw UnimplementedError();
}
}
Future<void> _downloadFileAndroid(Account account, File file) async {
final fileRepo = FileRepo(FileCachedDataSource());
final fileContent = await fileRepo.getBinary(account, file);
try {
await MediaStore.saveFileToDownload(
path.basename(file.path), fileContent);
} on PlatformException catch (e) {
if (e.code == MediaStore.exceptionCodePermissionError) {
throw PermissionException();
} else {
rethrow;
}
}
}
}

View file

@ -0,0 +1,92 @@
import 'dart:async';
import 'package:exifdart/exifdart_io.dart';
import 'package:exifdart/exifdart_memory.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:image_size_getter/image_size_getter.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/cache_manager_util.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/image_size_getter_util.dart';
import 'package:nc_photos/platform/metadata_loader.dart' as itf;
class MetadataLoader implements itf.MetadataLoader {
@override
loadCacheFile(Account account, File file) async {
final getFileFuture =
_getFileTask.getFileUntil(api_util.getFileUrl(account, file));
final result = await Future.any([
getFileFuture,
Future.delayed(Duration(seconds: 10)),
]);
if (_getFileTask.isGood && result is FileInfo) {
return _onGetFile(result);
} else {
// timeout
_getFileTask.cancel();
throw TimeoutException("Timeout loading file: ${file.strippedPath}");
}
}
@override
loadNewFile(Account account, File file) async {
final response =
await Api(account).files().get(path: api_util.getFileUrlRelative(file));
if (!response.isGood) {
_log.severe("[forceLoadFile] Failed requesting server: $response");
throw ApiException(
response: response,
message: "Failed communicating with server: ${response.statusCode}");
}
final resolution =
await AsyncImageSizeGetter.getSize(AsyncMemoryInput(response.body));
final exif = await readExifFromBytes(response.body);
return {
if (exif != null) "exif": exif,
if (resolution.width > 0 && resolution.height > 0)
"resolution": {
"width": resolution.width,
"height": resolution.height,
},
};
}
@override
loadFile(Account account, File file) async {
final store = DefaultCacheManager().store;
final info = await store.getFile(api_util.getFileUrl(account, file));
if (info == null) {
// no cache
return loadNewFile(account, file);
} else {
return _onGetFile(info);
}
}
@override
cancel() {
_getFileTask.cancel();
}
Future<Map<String, dynamic>> _onGetFile(FileInfo f) async {
final resolution =
await AsyncImageSizeGetter.getSize(AsyncFileInput(f.file));
final exif = await readExifFromFile(f.file);
return {
if (exif != null) "exif": exif,
if (resolution.width > 0 && resolution.height > 0)
"resolution": {
"width": resolution.width,
"height": resolution.height,
},
};
}
final _getFileTask = CancelableGetFile(DefaultCacheManager().store);
static final _log = Logger("mobile.metadata_loader.MetadataLoader");
}

7
lib/mobile/my_app.dart Normal file
View file

@ -0,0 +1,7 @@
import 'package:idb_sqflite/idb_sqflite.dart';
import 'package:nc_photos/widget/my_app.dart' as itf;
import 'package:sqflite/sqflite.dart';
class MyApp extends itf.MyApp {
static IdbFactory getDbFactory() => getIdbFactorySqflite(databaseFactory);
}

3
lib/mobile/platform.dart Normal file
View file

@ -0,0 +1,3 @@
export 'downloader.dart';
export 'metadata_loader.dart';
export 'my_app.dart';

View file

@ -0,0 +1,7 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
abstract class Downloader {
/// Download file to device
Future<void> downloadFile(Account account, File file);
}

View file

@ -0,0 +1,21 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
abstract class MetadataLoader {
/// Load metadata for [file] from cache
///
/// If the file is not found in cache after a certain amount of time, an
/// exception will be thrown
Future<Map<String, dynamic>> loadCacheFile(Account account, File file);
/// Download and load metadata for [file]
///
/// This function will always try to download the file, no matter it's cached
/// or not
Future<Map<String, dynamic>> loadNewFile(Account account, File file);
/// Load metadata for [file], either from cache or a new download
Future<Map<String, dynamic>> loadFile(Account account, File file);
void cancel();
}

68
lib/pref.dart Normal file
View file

@ -0,0 +1,68 @@
import 'dart:convert';
import 'package:nc_photos/account.dart';
import 'package:shared_preferences/shared_preferences.dart';
class Pref {
static Future<void> init() async {
return SharedPreferences.getInstance().then((pref) {
_inst._pref = pref;
});
}
factory Pref.inst() => _inst;
List<Account> getAccounts([List<Account> def]) {
final jsonObjs = _pref.getStringList("accounts");
return jsonObjs?.map((e) => Account.fromJson(jsonDecode(e)))?.toList() ??
def;
}
Future<bool> setAccounts(List<Account> value) {
final jsons = value.map((e) => jsonEncode(e.toJson())).toList();
return _pref.setStringList("accounts", jsons);
}
int getCurrentAccountIndex([int def]) =>
_pref.getInt("currentAccountIndex") ?? def;
Future<bool> setCurrentAccountIndex(int value) =>
_pref.setInt("currentAccountIndex", value);
int getHomePhotosZoomLevel([int def]) =>
_pref.getInt("homePhotosZoomLevel") ?? def;
Future<bool> setHomePhotosZoomLevel(int value) =>
_pref.setInt("homePhotosZoomLevel", value);
int getAlbumViewerZoomLevel([int def]) =>
_pref.getInt("albumViewerZoomLevel") ?? def;
Future<bool> setAlbumViewerZoomLevel(int value) =>
_pref.setInt("albumViewerZoomLevel", value);
bool isEnableExif([bool def = true]) => _pref.getBool("isEnableExif") ?? def;
Future<bool> setEnableExif(bool value) =>
_pref.setBool("isEnableExif", value);
int getSetupProgress([int def = 0]) => _pref.getInt("setupProgress") ?? def;
Future<bool> setSetupProgress(int value) =>
_pref.setInt("setupProgress", value);
Pref._();
static final _inst = Pref._();
SharedPreferences _pref;
}
extension PrefExtension on Pref {
Account getCurrentAccount() {
try {
return Pref.inst().getAccounts()[Pref.inst().getCurrentAccountIndex()];
} catch (_) {
return null;
}
}
}

View file

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
/// Showing snack bars
///
/// This manager helps showing a snack bar even after the context was
/// invalidated by having another widget (presumably top-level) to handle such
/// request in a decoupled way
class SnackBarManager {
factory SnackBarManager() => _inst;
SnackBarManager._();
void registerHandler(SnackBarHandler handler) {
_handlers.add(handler);
}
void unregisterHandler(SnackBarHandler handler) {
_handlers.remove(handler);
}
/// Show a snack bar if possible
///
/// If the snack bar can't be shown at this time, return null
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showSnackBar(
SnackBar snackBar) {
for (final h in _handlers.reversed) {
final result = h.showSnackBar(snackBar);
if (result != null) {
return result;
}
}
_log.warning("[showSnackBar] No handler available");
return null;
}
final _handlers = <SnackBarHandler>[];
static final _inst = SnackBarManager._();
final _log = Logger("snack_bar_manager.SnackBarManager");
}
abstract class SnackBarHandler {
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showSnackBar(
SnackBar snackBar);
}

26
lib/string_extension.dart Normal file
View file

@ -0,0 +1,26 @@
extension StringExtension on String {
/// Returns the string without any leading characters included in [characters]
String trimLeftAny(String characters) {
int i = 0;
while (i < length && characters.contains(this[i])) {
i += 1;
}
return this.substring(i);
}
/// Returns the string without any trailing characters included in
/// [characters]
String trimRightAny(String characters) {
int i = 0;
while (i < length && characters.contains(this[length - 1 - i])) {
i += 1;
}
return this.substring(0, length - i);
}
/// Returns the string without any leading and trailing characters included in
/// [characters]
String trimAny(String characters) {
return trimLeftAny(characters).trimRightAny(characters);
}
}

100
lib/theme.dart Normal file
View file

@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class AppTheme extends StatelessWidget {
const AppTheme({@required this.child});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Theme(
data: theme.brightness == Brightness.light
? _buildLightThemeData(context, theme)
: _buildDarkThemeData(context, theme),
child: child,
);
}
ThemeData _buildLightThemeData(BuildContext context, ThemeData theme) {
final appBarTheme = theme.appBarTheme.copyWith(
brightness: Brightness.dark,
color: theme.scaffoldBackgroundColor,
actionsIconTheme: theme.primaryIconTheme.copyWith(color: Colors.black),
iconTheme: theme.primaryIconTheme.copyWith(color: Colors.black),
textTheme: theme.primaryTextTheme.apply(bodyColor: Colors.black),
);
return theme.copyWith(appBarTheme: appBarTheme);
}
ThemeData _buildDarkThemeData(BuildContext context, ThemeData theme) {
final appBarTheme = theme.appBarTheme.copyWith(
brightness: Brightness.dark,
color: theme.scaffoldBackgroundColor,
actionsIconTheme: theme.primaryIconTheme.copyWith(color: Colors.white),
iconTheme: theme.primaryIconTheme.copyWith(color: Colors.white),
textTheme: theme.primaryTextTheme.apply(bodyColor: Colors.white),
);
return theme.copyWith(appBarTheme: appBarTheme);
}
static AppBarTheme getContextualAppBarTheme(BuildContext context) {
final theme = Theme.of(context);
if (theme.brightness == Brightness.light) {
return theme.appBarTheme.copyWith(
brightness: Brightness.dark,
color: Colors.grey[800],
actionsIconTheme: theme.primaryIconTheme.copyWith(color: Colors.white),
iconTheme: theme.primaryIconTheme.copyWith(color: Colors.white),
textTheme: theme.primaryTextTheme.apply(bodyColor: Colors.white),
);
} else {
return theme.appBarTheme.copyWith(
brightness: Brightness.dark,
color: Colors.grey[200],
actionsIconTheme: theme.primaryIconTheme.copyWith(color: Colors.black),
iconTheme: theme.primaryIconTheme.copyWith(color: Colors.black),
textTheme: theme.primaryTextTheme.apply(bodyColor: Colors.black),
);
}
}
static Color getSelectionOverlayColor(BuildContext context) {
return Theme.of(context).brightness == Brightness.light
? primarySwatchLight[100].withOpacity(0.7)
: primarySwatchDark[100].withOpacity(0.7);
}
static Color getSelectionCheckColor(BuildContext context) {
return Theme.of(context).brightness == Brightness.light
? Colors.grey[800]
: Colors.grey[200];
}
static Color getOverscrollIndicatorColor(BuildContext context) {
return Theme.of(context).brightness == Brightness.light
? Colors.grey[800]
: Colors.grey[200];
}
static Color getRootPickerContentBoxColor(BuildContext context) {
return Colors.blue[200];
}
static const primarySwatchLight = Colors.blue;
static const primarySwatchDark = Colors.cyan;
static const widthLimitedContentMaxWidth = 550.0;
/// Make a TextButton look like a default FlatButton. See
/// https://flutter.dev/go/material-button-migration-guide
static final flatButtonStyle = TextButton.styleFrom(
primary: Colors.black87,
minimumSize: Size(88, 36),
padding: EdgeInsets.symmetric(horizontal: 16.0),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(2.0)),
),
);
final Widget child;
}

View file

@ -0,0 +1,19 @@
import 'package:event_bus/event_bus.dart';
import 'package:kiwi/kiwi.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/event/event.dart';
class CreateAlbum {
CreateAlbum(this.albumRepo);
Future<Album> call(Account account, Album album) async {
final newAlbum = await albumRepo.create(account, album);
KiwiContainer()
.resolve<EventBus>()
.fire(AlbumCreatedEvent(account, newAlbum));
return newAlbum;
}
final AlbumRepo albumRepo;
}

View file

@ -0,0 +1,14 @@
import 'dart:typed_data';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
class GetFileBinary {
GetFileBinary(this.fileRepo);
/// Get the binary content of a file
Future<Uint8List> call(Account account, File file) =>
fileRepo.getBinary(account, file);
final FileRepo fileRepo;
}

View file

@ -0,0 +1,44 @@
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/use_case/ls.dart';
class ListAlbum {
ListAlbum(this.fileRepo, this.albumRepo);
/// List all albums associated with [account]
Future<List<Album>> call(Account account) async {
try {
final albumFiles = await Ls(fileRepo)(
account,
File(
path: getAlbumFileRoot(account),
));
final albums = <Album>[];
for (final f in albumFiles) {
final album = await albumRepo.get(account, f);
albums.add(album);
}
try {
albumRepo.cleanUp(account, albumFiles);
} catch (e, stacktrace) {
// not important, log and ignore
_log.severe("[call] Failed while cleanUp", e, stacktrace);
}
return albums;
} catch (e) {
if (e is ApiException && e.response.statusCode == 404) {
// no albums
return [];
}
rethrow;
}
}
final FileRepo fileRepo;
final AlbumRepo albumRepo;
static final _log = Logger("use_case.list_album.ListAlbum");
}

27
lib/use_case/ls.dart Normal file
View file

@ -0,0 +1,27 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/string_extension.dart';
class Ls {
Ls(this.fileRepo);
/// List all files under a dir
///
/// The resulting list would normally also include the [root] dir. If
/// [shouldExcludeRootDir] == true, such entry will be removed
Future<List<File>> call(Account account, File root,
{bool shouldExcludeRootDir = true}) async {
final products = await fileRepo.list(account, root);
if (shouldExcludeRootDir) {
// filter out root file
final trimmedRootPath = root.path.trimAny("/");
return products
.where((element) => element.path.trimAny("/") != trimmedRootPath)
.toList();
} else {
return products;
}
}
final FileRepo fileRepo;
}

View file

@ -0,0 +1,14 @@
import 'dart:typed_data';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
class PutFileBinary {
PutFileBinary(this.fileRepo);
/// Upload file to [path]
Future<void> call(Account account, String path, Uint8List content) =>
fileRepo.putBinary(account, path, content);
final FileRepo fileRepo;
}

48
lib/use_case/remove.dart Normal file
View file

@ -0,0 +1,48 @@
import 'package:event_bus/event_bus.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/use_case/list_album.dart';
import 'package:nc_photos/use_case/update_album.dart';
class Remove {
Remove(this.fileRepo, this.albumRepo);
/// Remove a file
Future<void> call(Account account, File file) async {
await fileRepo.remove(account, file);
await _cleanUpAlbums(account, file);
KiwiContainer().resolve<EventBus>().fire(FileRemovedEvent(account, file));
}
Future<void> _cleanUpAlbums(Account account, File file) async {
final albums = await ListAlbum(fileRepo, albumRepo)(account);
for (final a in albums) {
try {
if (a.items.any((element) =>
element is AlbumFileItem && element.file.path == file.path)) {
final newItems = a.items.where((element) {
if (element is AlbumFileItem) {
return element.file.path != file.path;
} else {
return true;
}
}).toList();
await UpdateAlbum(albumRepo)(account, a.copyWith(items: newItems));
}
} catch (e, stacktrace) {
_log.severe(
"[_cleanUpAlbums] Failed while updating album", e, stacktrace);
// continue to next album
}
}
}
final FileRepo fileRepo;
final AlbumRepo albumRepo;
static final _log = Logger("use_case.remove.Remove");
}

View file

@ -0,0 +1,44 @@
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/use_case/ls.dart';
import 'package:path/path.dart' as path;
class ScanDir {
ScanDir(this.fileRepo);
/// List all files under a dir recursively
///
/// Dirs with a .nomedia/.noimage file will be ignored. The returned stream
/// would emit either List<File> data or an exception
Stream<dynamic> call(Account account, File root) async* {
try {
final items = await Ls(fileRepo)(account, root);
if (_shouldScanIgnoreDir(items)) {
return;
}
yield items.where((element) => element.isCollection != true).toList();
for (final i in items.where((element) => element.isCollection == true)) {
yield* this(account, i);
}
} catch (e, stacktrace) {
_log.severe("[call] Failed scanning \"${root.path}\"", e, stacktrace);
// for some reason exception thrown here can't be caught outside
// rethrow;
yield e;
}
}
/// Return if this dir should be ignored in a scan op based on files under
/// this dir
static bool _shouldScanIgnoreDir(Iterable<File> files) {
return files.any((element) {
final basename = path.basename(element.path);
return basename == ".nomedia" || basename == ".noimage";
});
}
final FileRepo fileRepo;
static final _log = Logger("use_case.scan_dir.ScanDir");
}

View file

@ -0,0 +1,30 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/use_case/scan_dir.dart';
class ScanMissingMetadata {
ScanMissingMetadata(this.fileRepo);
/// List all files that support metadata but yet having one under a dir
/// recursively
///
/// Dirs with a .nomedia/.noimage file will be ignored. The returned stream
/// would emit either File data or an exception
Stream<dynamic> call(Account account, File root) async* {
final dataStream = ScanDir(fileRepo)(account, root);
await for (final d in dataStream) {
if (d is Exception || d is Error) {
yield d;
continue;
}
final missingMetadata = (d as List<File>).where((element) =>
file_util.isSupportedFormat(element) && element.metadata == null);
for (final f in missingMetadata) {
yield f;
}
}
}
final FileRepo fileRepo;
}

View file

@ -0,0 +1,16 @@
import 'package:event_bus/event_bus.dart';
import 'package:kiwi/kiwi.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/event/event.dart';
class UpdateAlbum {
UpdateAlbum(this.albumRepo);
Future<void> call(Account account, Album album) async {
await albumRepo.update(account, album);
KiwiContainer().resolve<EventBus>().fire(AlbumUpdatedEvent(account, album));
}
final AlbumRepo albumRepo;
}

View file

@ -0,0 +1,56 @@
import 'package:event_bus/event_bus.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/use_case/list_album.dart';
import 'package:nc_photos/use_case/update_album.dart';
class UpdateMetadata {
UpdateMetadata(this.fileRepo, this.albumRepo);
Future<void> call(Account account, File file, Metadata metadata) async {
if (file.etag != metadata.fileEtag) {
_log.warning(
"[call] Metadata fileEtag mismatch with actual file's (metadata: ${metadata.fileEtag}, file: ${file.etag})");
}
await fileRepo.updateMetadata(account, file, metadata);
await _cleanUpAlbums(account, file, metadata);
KiwiContainer()
.resolve<EventBus>()
.fire(FileMetadataUpdatedEvent(account, file));
}
Future<void> _cleanUpAlbums(
Account account, File file, Metadata metadata) async {
final albums = await ListAlbum(fileRepo, albumRepo)(account);
for (final a in albums) {
try {
if (a.items.any((element) =>
element is AlbumFileItem && element.file.path == file.path)) {
final newItems = a.items.map((e) {
if (e is AlbumFileItem && e.file.path == file.path) {
return AlbumFileItem(
file: e.file.copyWith(metadata: metadata),
);
} else {
return e;
}
}).toList();
await UpdateAlbum(albumRepo)(account, a.copyWith(items: newItems));
}
} catch (e, stacktrace) {
_log.severe(
"[_cleanUpAlbums] Failed while updating album", e, stacktrace);
// continue to next album
}
}
}
final FileRepo fileRepo;
final AlbumRepo albumRepo;
static final _log = Logger("use_case.update_metadata.UpdateMetadata");
}

View file

@ -0,0 +1,73 @@
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/connectivity_util.dart' as connectivity_util;
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/exif.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
import 'package:nc_photos/use_case/scan_missing_metadata.dart';
import 'package:nc_photos/use_case/update_metadata.dart';
class UpdateMissingMetadata {
UpdateMissingMetadata(this.fileRepo);
/// Update metadata for all files that support one under a dir recursively
///
/// Dirs with a .nomedia/.noimage file will be ignored. The returned stream
/// would emit either File data (for each updated files) or an exception
Stream<dynamic> call(Account account, File root) async* {
final dataStream = ScanMissingMetadata(fileRepo)(account, root);
final metadataLoader = platform.MetadataLoader();
await for (final d in dataStream) {
if (d is Exception || d is Error) {
yield d;
continue;
}
try {
// since we need to download multiple images in their original size,
// we only do it with WiFi
await connectivity_util.waitUntilWifi();
if (!shouldRun) {
return;
}
final File file = d;
_log.fine("[call] Updating metadata for ${file.path}");
final metadata = await metadataLoader.loadFile(account, file);
int imageWidth, imageHeight;
Exif exif;
if (metadata.containsKey("resolution")) {
imageWidth = metadata["resolution"]["width"];
imageHeight = metadata["resolution"]["height"];
}
if (metadata.containsKey("exif")) {
exif = Exif(metadata["exif"]);
}
final metadataObj = Metadata(
fileEtag: file.etag,
imageWidth: imageWidth,
imageHeight: imageHeight,
exif: exif,
);
await UpdateMetadata(FileRepo(FileCachedDataSource()),
AlbumRepo(AlbumCachedDataSource()))(account, file, metadataObj);
yield file;
} catch (e, stacktrace) {
_log.severe("[call] Failed while getting metadata", e, stacktrace);
yield e;
}
}
}
void stop() {
shouldRun = false;
}
final FileRepo fileRepo;
bool shouldRun = true;
static final _log =
Logger("use_case.update_missing_metadata.UpdateMissingMetadata");
}

21
lib/web/downloader.dart Normal file
View file

@ -0,0 +1,21 @@
// ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html;
// ignore: avoid_web_libraries_in_flutter
import 'dart:js' as js;
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/platform/downloader.dart' as itf;
import 'package:path/path.dart' as path;
class Downloader extends itf.Downloader {
@override
downloadFile(Account account, File file) async {
final fileRepo = FileRepo(FileCachedDataSource());
final fileContent = await fileRepo.getBinary(account, file);
js.context.callMethod("webSaveAs", [
html.Blob([fileContent]),
path.basename(file.path),
]);
}
}

View file

@ -0,0 +1,50 @@
import 'dart:io';
import 'package:exifdart/exifdart_memory.dart';
import 'package:image_size_getter/image_size_getter.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/image_size_getter_util.dart';
import 'package:nc_photos/platform/metadata_loader.dart' as itf;
class MetadataLoader implements itf.MetadataLoader {
// on web we just download the image again, hopefully the browser would
// cache it for us (which is sadly not the case :|
@override
loadCacheFile(Account account, File file) => loadNewFile(account, file);
@override
loadNewFile(Account account, File file) async {
final response =
await Api(account).files().get(path: api_util.getFileUrlRelative(file));
if (!response.isGood) {
_log.severe("[loadFile] Failed requesting server: $response");
throw ApiException(
response: response,
message: "Failed communicating with server: ${response.statusCode}");
}
final resolution =
await AsyncImageSizeGetter.getSize(AsyncMemoryInput(response.body));
final exif = await readExifFromBytes(response.body);
return {
if (exif != null) "exif": exif,
if (resolution.width > 0 && resolution.height > 0)
"resolution": {
"width": resolution.width,
"height": resolution.height,
},
};
}
@override
loadFile(Account account, File file) => loadNewFile(account, file);
@override
cancel() {}
static final _log = Logger("web.metadata_loader.MetadataLoader");
}

7
lib/web/my_app.dart Normal file
View file

@ -0,0 +1,7 @@
import 'package:idb_shim/idb_browser.dart';
import 'package:idb_shim/idb_shim.dart';
import 'package:nc_photos/widget/my_app.dart' as itf;
class MyApp extends itf.MyApp {
static IdbFactory getDbFactory() => getIdbFactory();
}

3
lib/web/platform.dart Normal file
View file

@ -0,0 +1,3 @@
export 'downloader.dart';
export 'metadata_loader.dart';
export 'my_app.dart';

View file

@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/widget/home.dart';
import 'package:nc_photos/widget/sign_in.dart';
class AccountPickerDialog extends StatefulWidget {
AccountPickerDialog({
Key key,
@required this.account,
}) : super(key: key);
@override
createState() => _AccountPickerDialogState();
final Account account;
}
class _AccountPickerDialogState extends State<AccountPickerDialog> {
@override
initState() {
super.initState();
_accounts = Pref.inst().getAccounts([]);
}
@override
build(BuildContext context) {
final otherAccountOptions = _accounts
.where((a) => a != widget.account)
.map((a) => SimpleDialogOption(
onPressed: () => _onItemPressed(a),
child: ListTile(
title: Text(a.url),
subtitle: Text(a.username),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () => _onRemoveItemPressed(a)),
),
))
.toList();
final addAccountOptions = [
SimpleDialogOption(
onPressed: () {
Navigator.of(context)
..pop()
..pushNamed(SignIn.routeName);
},
child: Tooltip(
message: AppLocalizations.of(context).addServerTooltip,
child: const Center(
child: const Icon(Icons.add, color: Colors.black54),
),
),
),
];
return SimpleDialog(
title: ListTile(
title: Text(
widget.account.url,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
widget.account.username,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
children: otherAccountOptions + addAccountOptions,
);
}
void _onItemPressed(Account account) {
Pref.inst().setCurrentAccountIndex(_accounts.indexOf(account));
Navigator.of(context).pushNamedAndRemoveUntil(Home.routeName, (_) => false,
arguments: HomeArguments(account));
}
void _onRemoveItemPressed(Account account) {
_removeAccount(account);
setState(() {
_accounts = Pref.inst().getAccounts([]);
});
SnackBarManager().showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)
.removeServerSuccessNotification(account.url)),
duration: k.snackBarDurationNormal,
));
}
void _removeAccount(Account account) {
final currentAccounts = Pref.inst().getAccounts([]);
final currentAccount =
currentAccounts[Pref.inst().getCurrentAccountIndex()];
final newAccounts =
currentAccounts.where((element) => element != account).toList();
final newAccountIndex = newAccounts.indexOf(currentAccount);
if (newAccountIndex == -1) {
throw StateError("Active account not found in resulting account list");
}
Pref.inst()
..setAccounts(newAccounts)
..setCurrentAccountIndex(newAccountIndex);
}
List<Account> _accounts;
}

View file

@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:nc_photos/theme.dart';
class AlbumGridItem extends StatelessWidget {
AlbumGridItem({
Key key,
@required this.cover,
@required this.title,
this.subtitle,
this.isSelected = false,
this.onTap,
this.onLongPress,
}) : super(key: key);
@override
build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: cover,
),
const SizedBox(height: 8),
Text(
title ?? "",
style: Theme.of(context).textTheme.bodyText1,
textAlign: TextAlign.start,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
subtitle ?? "",
style: Theme.of(context).textTheme.bodyText2.copyWith(
fontSize: 10,
color: Colors.grey,
),
textAlign: TextAlign.start,
maxLines: 1,
),
],
),
),
if (isSelected)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: AppTheme.getSelectionOverlayColor(context),
borderRadius: BorderRadius.circular(8),
),
),
),
if (isSelected)
Positioned.fill(
child: Align(
alignment: Alignment.center,
child: Icon(
Icons.check_circle_outlined,
size: 48,
color: AppTheme.getSelectionCheckColor(context),
),
),
),
Positioned.fill(
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(8),
),
),
)
],
);
}
final Widget cover;
final String title;
final String subtitle;
final bool isSelected;
final VoidCallback onTap;
final VoidCallback onLongPress;
}

View file

@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/bloc/list_album.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/widget/new_album_dialog.dart';
class AlbumPickerDialog extends StatefulWidget {
AlbumPickerDialog({
Key key,
@required this.account,
}) : super(key: key);
@override
createState() => _AlbumPickerDialogState();
final Account account;
}
class _AlbumPickerDialogState extends State<AlbumPickerDialog> {
@override
initState() {
super.initState();
_initBloc();
}
@override
build(BuildContext context) {
return BlocListener<ListAlbumBloc, ListAlbumBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: BlocBuilder<ListAlbumBloc, ListAlbumBlocState>(
bloc: _bloc,
builder: (context, state) => _buildContent(context, state),
),
);
}
void _initBloc() {
ListAlbumBloc bloc;
final blocId =
"${widget.account.scheme}://${widget.account.username}@${widget.account.address}";
try {
_log.fine("[_initBloc] Resolving bloc for '$blocId'");
bloc = KiwiContainer().resolve<ListAlbumBloc>("ListAlbumBloc($blocId)");
} catch (_) {
// no created instance for this account, make a new one
_log.info("[_initBloc] New bloc instance for account: ${widget.account}");
bloc = ListAlbumBloc();
KiwiContainer().registerInstance<ListAlbumBloc>(bloc,
name: "ListAlbumBloc($blocId)");
}
_bloc = bloc;
if (_bloc.state is ListAlbumBlocInit) {
_log.info("[_initBloc] Initialize bloc");
_reqQuery();
} else {
// process the current state
_onStateChange(context, _bloc.state);
}
}
Widget _buildContent(BuildContext context, ListAlbumBlocState state) {
final newAlbumOptions = [
SimpleDialogOption(
onPressed: () => _onNewAlbumPressed(context),
child: Tooltip(
message: AppLocalizations.of(context).createAlbumTooltip,
child: const Center(
child: const Icon(Icons.add, color: Colors.black54),
),
),
),
];
return Visibility(
visible: _isVisible,
child: SimpleDialog(
children: _items
.map((e) => SimpleDialogOption(
onPressed: () => _onItemPressed(context, e),
child: ListTile(
title: Text("${e.name}"),
),
))
.toList() +
newAlbumOptions,
),
);
}
void _onStateChange(BuildContext context, ListAlbumBlocState state) {
if (state is ListAlbumBlocInit) {
_items.clear();
} else if (state is ListAlbumBlocSuccess || state is ListAlbumBlocLoading) {
_transformItems(state.albums);
} else if (state is ListAlbumBlocFailure) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(state.exception, context)),
duration: k.snackBarDurationNormal,
));
} else if (state is ListAlbumBlocInconsistent) {
_reqQuery();
}
}
void _onItemPressed(BuildContext context, Album album) {
Navigator.of(context).pop(album);
}
void _onNewAlbumPressed(BuildContext context) {
setState(() {
_isVisible = false;
});
showDialog(
context: context,
builder: (_) => NewAlbumDialog(
account: widget.account,
),
).then((value) {
Navigator.of(context).pop(value);
}).catchError((e, stacktrace) {
_log.severe(
"[_onNewAlbumPressed] Failed while showDialog", e, stacktrace);
Navigator.of(context).pop(e);
});
}
void _transformItems(List<Album> albums) {
_items.clear();
_items.addAll(albums);
}
void _reqQuery() {
_bloc.add(ListAlbumBlocQuery(widget.account));
}
ListAlbumBloc _bloc;
final _items = <Album>[];
var _isVisible = true;
static final _log =
Logger("widget.album_picker_dialog._AlbumPickerDialogState");
}

View file

@ -0,0 +1,354 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/list_extension.dart';
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/update_album.dart';
import 'package:nc_photos/widget/image_grid_item.dart';
import 'package:nc_photos/widget/popup_menu_zoom.dart';
import 'package:nc_photos/widget/viewer.dart';
class AlbumViewerArguments {
AlbumViewerArguments(this.account, this.album);
final Account account;
final Album album;
}
class AlbumViewer extends StatefulWidget {
static const routeName = "/album-viewer";
AlbumViewer({
Key key,
@required this.account,
@required this.album,
}) : super(key: key);
AlbumViewer.fromArgs(AlbumViewerArguments args, {Key key})
: this(
key: key,
account: args.account,
album: args.album,
);
@override
createState() => _AlbumViewerState();
final Account account;
final Album album;
}
class _AlbumViewerState extends State<AlbumViewer>
with TickerProviderStateMixin {
@override
initState() {
super.initState();
_album = widget.album;
_transformItems();
_initCover();
_thumbZoomLevel = Pref.inst().getAlbumViewerZoomLevel(0);
}
@override
build(BuildContext context) {
return AppTheme(
child: Scaffold(
body: Builder(builder: (context) => _buildContent(context)),
),
);
}
void _initCover() {
try {
final coverFile = _backingFiles.first;
if (coverFile.hasPreview) {
_coverPreviewUrl = api_util.getFilePreviewUrl(widget.account, coverFile,
width: 1024, height: 600);
} else {
_coverPreviewUrl = api_util.getFileUrl(widget.account, coverFile);
}
} catch (_) {}
}
Widget _buildContent(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
accentColor: AppTheme.getOverscrollIndicatorColor(context),
),
child: CustomScrollView(
slivers: [
_buildAppBar(context),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverStaggeredGrid.extentBuilder(
// need to rebuild grid after zoom level changed
key: ValueKey(_thumbZoomLevel),
maxCrossAxisExtent: _thumbSize.toDouble(),
itemCount: _items.length,
itemBuilder: _buildItem,
staggeredTileBuilder: (index) => const StaggeredTile.count(1, 1),
),
),
],
),
);
}
Widget _buildAppBar(BuildContext context) {
if (_isSelectionMode) {
return _buildSelectionAppBar(context);
} else {
return _buildNormalAppBar(context);
}
}
Widget _buildSelectionAppBar(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
appBarTheme: AppTheme.getContextualAppBarTheme(context),
),
child: SliverAppBar(
pinned: true,
leading: IconButton(
icon: const Icon(Icons.close),
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
onPressed: () {
setState(() {
_selectedItems.clear();
});
},
),
title: Text(AppLocalizations.of(context)
.selectionAppBarTitle(_selectedItems.length)),
actions: [
IconButton(
icon: const Icon(Icons.remove),
tooltip:
AppLocalizations.of(context).removeSelectedFromAlbumTooltip,
onPressed: () {
_onSelectionAppBarRemovePressed();
},
)
],
),
);
}
Widget _buildNormalAppBar(BuildContext context) {
Widget cover;
try {
if (_coverPreviewUrl != null) {
cover = Opacity(
opacity: 0.25,
child: FittedBox(
clipBehavior: Clip.hardEdge,
fit: BoxFit.cover,
child: CachedNetworkImage(
imageUrl: _coverPreviewUrl,
httpHeaders: {
"Authorization":
Api.getAuthorizationHeaderValue(widget.account),
},
filterQuality: FilterQuality.high,
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
),
),
);
}
} catch (_) {}
return SliverAppBar(
floating: true,
expandedHeight: 160,
flexibleSpace: FlexibleSpaceBar(
background: cover,
title: Text(
_album.name,
style: TextStyle(
color: Theme.of(context).textTheme.headline6.color,
),
),
),
actions: [
PopupMenuButton(
icon: const Icon(Icons.zoom_in),
tooltip: AppLocalizations.of(context).zoomTooltip,
itemBuilder: (context) => [
PopupMenuZoom(
initialValue: _thumbZoomLevel,
onChanged: (value) {
setState(() {
_thumbZoomLevel = value.round();
});
Pref.inst().setAlbumViewerZoomLevel(_thumbZoomLevel);
},
),
],
),
],
);
}
Widget _buildItem(BuildContext context, int index) {
final item = _items[index];
if (item is _GridImageItem) {
return _buildImageItem(context, item, index);
} else {
_log.severe("[_buildItem] Unsupported item type: ${item.runtimeType}");
throw StateError("Unsupported item type: ${item.runtimeType}");
}
}
Widget _buildImageItem(BuildContext context, _GridImageItem item, int index) {
return ImageGridItem(
account: widget.account,
imageUrl: item.previewUrl,
isSelected: _selectedItems.contains(item),
onTap: () => _onItemTap(item, index),
onLongPress:
_isSelectionMode ? null : () => _onItemLongPress(item, index),
);
}
void _onItemTap(_GridItem item, int index) {
if (_isSelectionMode) {
if (_selectedItems.contains(item)) {
// unselect
setState(() {
_selectedItems.remove(item);
});
} else {
// select
setState(() {
_selectedItems.add(item);
});
}
} else {
Navigator.pushNamed(context, Viewer.routeName,
arguments: ViewerArguments(widget.account, _backingFiles, index));
}
}
void _onItemLongPress(_GridItem item, int index) {
setState(() {
_selectedItems.add(item);
});
}
void _onSelectionAppBarRemovePressed() {
// currently album's are auto sorted by date, so it's ok to remove items w/o
// preserving the order. this will be problematic if we want to allow custom
// sorting later
final selectedIndexes =
_selectedItems.map((e) => _items.indexOf(e)).toList();
final selectedFiles = _backingFiles.takeIndex(selectedIndexes).toList();
final newItems = _album.items.where((element) {
if (element is AlbumFileItem) {
return !selectedFiles.contains(element.file);
} else {
return true;
}
}).toList();
final albumRepo = AlbumRepo(AlbumCachedDataSource());
final newAlbum = _album.copyWith(
items: newItems,
);
UpdateAlbum(albumRepo)(widget.account, newAlbum).then((_) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)
.removeSelectedFromAlbumSuccessNotification(
selectedIndexes.length)),
duration: k.snackBarDurationNormal,
));
setState(() {
_album = newAlbum;
_transformItems();
});
}).catchError((e, stacktrace) {
_log.severe("[_onSelectionRemovePressed] Failed while updating album", e,
stacktrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(
"${AppLocalizations.of(context).removeSelectedFromAlbumFailureNotification}: "
"${exception_util.toUserString(e, context)}"),
duration: k.snackBarDurationNormal,
));
});
setState(() {
_selectedItems.clear();
});
}
void _transformItems() {
_backingFiles = _album.items
.whereType<AlbumFileItem>()
.map((e) => e.file)
.where((element) => file_util.isSupportedFormat(element))
.sorted(compareFileDateTimeDescending);
_items.clear();
_items.addAll(_backingFiles.map((e) {
var previewUrl;
if (e.hasPreview) {
previewUrl = api_util.getFilePreviewUrl(widget.account, e,
width: _thumbSize, height: _thumbSize);
} else {
previewUrl = api_util.getFileUrl(widget.account, e);
}
return _GridItem.image(previewUrl);
}));
}
int get _thumbSize {
switch (_thumbZoomLevel) {
case 1:
return 192;
case 2:
return 256;
case 0:
default:
return 112;
}
}
bool get _isSelectionMode => _selectedItems.isNotEmpty;
Album _album;
final _items = <_GridItem>[];
var _backingFiles = <File>[];
String _coverPreviewUrl;
var _thumbZoomLevel = 0;
final _selectedItems = <_GridItem>[];
static final _log = Logger("widget.album_viewer._AlbumViewerState");
}
class _GridItem {
const _GridItem();
factory _GridItem.image(String previewUrl) => _GridImageItem(previewUrl);
}
class _GridImageItem extends _GridItem {
_GridImageItem(this.previewUrl);
final String previewUrl;
}

View file

@ -0,0 +1,298 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:octo_image/octo_image.dart';
/// Builder function to create an image widget. The function is called after
/// the ImageProvider completes the image loading.
typedef ImageWidgetBuilder = Widget Function(
BuildContext context,
Widget child,
ImageProvider imageProvider,
);
/// Image widget to show NetworkImage with caching functionality.
class CachedNetworkImage extends StatelessWidget {
/// Evict an image from both the disk file based caching system of the
/// [BaseCacheManager] as the in memory [ImageCache] of the [ImageProvider].
/// [url] is used by both the disk and memory cache. The scale is only used
/// to clear the image from the [ImageCache].
static Future evictFromCache(
String url, {
String cacheKey,
BaseCacheManager cacheManager,
double scale = 1.0,
}) async {
cacheManager = cacheManager ?? DefaultCacheManager();
await cacheManager.removeFile(cacheKey ?? url);
return CachedNetworkImageProvider(url, scale: scale).evict();
}
final CachedNetworkImageProvider _image;
/// Option to use cachemanager with other settings
final BaseCacheManager cacheManager;
/// The target image that is displayed.
final String imageUrl;
/// The target image's cache key.
final String cacheKey;
/// Optional builder to further customize the display of the image.
final ImageWidgetBuilder imageBuilder;
/// Widget displayed while the target [imageUrl] is loading.
final PlaceholderWidgetBuilder placeholder;
/// Widget displayed while the target [imageUrl] is loading.
final ProgressIndicatorBuilder progressIndicatorBuilder;
/// Widget displayed while the target [imageUrl] failed loading.
final LoadingErrorWidgetBuilder errorWidget;
/// The duration of the fade-in animation for the [placeholder].
final Duration placeholderFadeInDuration;
/// The duration of the fade-out animation for the [placeholder].
final Duration fadeOutDuration;
/// The curve of the fade-out animation for the [placeholder].
final Curve fadeOutCurve;
/// The duration of the fade-in animation for the [imageUrl].
final Duration fadeInDuration;
/// The curve of the fade-in animation for the [imageUrl].
final Curve fadeInCurve;
/// If non-null, require the image to have this width.
///
/// If null, the image will pick a size that best preserves its intrinsic
/// aspect ratio. This may result in a sudden change if the size of the
/// placeholder widget does not match that of the target image. The size is
/// also affected by the scale factor.
final double width;
/// If non-null, require the image to have this height.
///
/// If null, the image will pick a size that best preserves its intrinsic
/// aspect ratio. This may result in a sudden change if the size of the
/// placeholder widget does not match that of the target image. The size is
/// also affected by the scale factor.
final double height;
/// How to inscribe the image into the space allocated during layout.
///
/// The default varies based on the other fields. See the discussion at
/// [paintImage].
final BoxFit fit;
/// How to align the image within its bounds.
///
/// The alignment aligns the given position in the image to the given position
/// in the layout bounds. For example, a [Alignment] alignment of (-1.0,
/// -1.0) aligns the image to the top-left corner of its layout bounds, while a
/// [Alignment] alignment of (1.0, 1.0) aligns the bottom right of the
/// image with the bottom right corner of its layout bounds. Similarly, an
/// alignment of (0.0, 1.0) aligns the bottom middle of the image with the
/// middle of the bottom edge of its layout bounds.
///
/// If the [alignment] is [TextDirection]-dependent (i.e. if it is a
/// [AlignmentDirectional]), then an ambient [Directionality] widget
/// must be in scope.
///
/// Defaults to [Alignment.center].
///
/// See also:
///
/// * [Alignment], a class with convenient constants typically used to
/// specify an [AlignmentGeometry].
/// * [AlignmentDirectional], like [Alignment] for specifying alignments
/// relative to text direction.
final AlignmentGeometry alignment;
/// How to paint any portions of the layout bounds not covered by the image.
final ImageRepeat repeat;
/// Whether to paint the image in the direction of the [TextDirection].
///
/// If this is true, then in [TextDirection.ltr] contexts, the image will be
/// drawn with its origin in the top left (the "normal" painting direction for
/// children); and in [TextDirection.rtl] contexts, the image will be drawn with
/// a scaling factor of -1 in the horizontal direction so that the origin is
/// in the top right.
///
/// This is occasionally used with children in right-to-left environments, for
/// children that were designed for left-to-right locales. Be careful, when
/// using this, to not flip children with integral shadows, text, or other
/// effects that will look incorrect when flipped.
///
/// If this is true, there must be an ambient [Directionality] widget in
/// scope.
final bool matchTextDirection;
/// Optional headers for the http request of the image url
final Map<String, String> httpHeaders;
/// When set to true it will animate from the old image to the new image
/// if the url changes.
final bool useOldImageOnUrlChange;
/// If non-null, this color is blended with each image pixel using [colorBlendMode].
final Color color;
/// Used to combine [color] with this image.
///
/// The default is [BlendMode.srcIn]. In terms of the blend mode, [color] is
/// the source and this image is the destination.
///
/// See also:
///
/// * [BlendMode], which includes an illustration of the effect of each blend mode.
final BlendMode colorBlendMode;
/// Target the interpolation quality for image scaling.
///
/// If not given a value, defaults to FilterQuality.low.
final FilterQuality filterQuality;
/// Will resize the image in memory to have a certain width using [ResizeImage]
final int memCacheWidth;
/// Will resize the image in memory to have a certain height using [ResizeImage]
final int memCacheHeight;
/// Will resize the image and store the resized image in the disk cache.
final int maxWidthDiskCache;
/// Will resize the image and store the resized image in the disk cache.
final int maxHeightDiskCache;
/// CachedNetworkImage shows a network image using a caching mechanism. It also
/// provides support for a placeholder, showing an error and fading into the
/// loaded image. Next to that it supports most features of a default Image
/// widget.
CachedNetworkImage({
Key key,
@required this.imageUrl,
this.httpHeaders,
this.imageBuilder,
this.placeholder,
this.progressIndicatorBuilder,
this.errorWidget,
this.fadeOutDuration = const Duration(milliseconds: 1000),
this.fadeOutCurve = Curves.easeOut,
this.fadeInDuration = const Duration(milliseconds: 500),
this.fadeInCurve = Curves.easeIn,
this.width,
this.height,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.matchTextDirection = false,
this.cacheManager,
this.useOldImageOnUrlChange = false,
this.color,
this.filterQuality = FilterQuality.low,
this.colorBlendMode,
this.placeholderFadeInDuration,
this.memCacheWidth,
this.memCacheHeight,
this.cacheKey,
this.maxWidthDiskCache,
this.maxHeightDiskCache,
ImageRenderMethodForWeb imageRenderMethodForWeb,
}) : assert(imageUrl != null),
assert(fadeOutDuration != null),
assert(fadeOutCurve != null),
assert(fadeInDuration != null),
assert(fadeInCurve != null),
assert(alignment != null),
assert(filterQuality != null),
assert(repeat != null),
assert(matchTextDirection != null),
_image = CachedNetworkImageProvider(
imageUrl,
headers: httpHeaders,
cacheManager: cacheManager,
cacheKey: cacheKey,
imageRenderMethodForWeb: imageRenderMethodForWeb,
maxWidth: maxWidthDiskCache,
maxHeight: maxHeightDiskCache,
),
super(key: key);
@override
Widget build(BuildContext context) {
var octoPlaceholderBuilder =
placeholder != null ? _octoPlaceholderBuilder : null;
var octoProgressIndicatorBuilder =
progressIndicatorBuilder != null ? _octoProgressIndicatorBuilder : null;
///If there is no placeholer OctoImage does not fade, so always set an
///(empty) placeholder as this always used to be the behaviour of
///CachedNetworkImage.
if (octoPlaceholderBuilder == null &&
octoProgressIndicatorBuilder == null) {
octoPlaceholderBuilder = (context) => Container();
}
return OctoImage(
image: _image,
imageBuilder: imageBuilder != null ? _octoImageBuilder : null,
placeholderBuilder: octoPlaceholderBuilder,
progressIndicatorBuilder: octoProgressIndicatorBuilder,
errorBuilder: errorWidget != null ? _octoErrorBuilder : null,
fadeOutDuration: fadeOutDuration,
fadeOutCurve: fadeOutCurve,
fadeInDuration: fadeInDuration,
fadeInCurve: fadeInCurve,
width: width,
height: height,
fit: fit,
alignment: alignment,
repeat: repeat,
matchTextDirection: matchTextDirection,
color: color,
filterQuality: filterQuality,
colorBlendMode: colorBlendMode,
placeholderFadeInDuration: placeholderFadeInDuration,
gaplessPlayback: useOldImageOnUrlChange,
memCacheWidth: memCacheWidth,
memCacheHeight: memCacheHeight,
);
}
Widget _octoImageBuilder(BuildContext context, Widget child) {
return imageBuilder(context, child, _image);
}
Widget _octoPlaceholderBuilder(BuildContext context) {
return placeholder(context, imageUrl);
}
Widget _octoProgressIndicatorBuilder(
BuildContext context,
ImageChunkEvent progress,
) {
int totalSize;
var downloaded = 0;
if (progress != null) {
totalSize = progress.expectedTotalBytes;
downloaded = progress.cumulativeBytesLoaded;
}
return progressIndicatorBuilder(
context, imageUrl, DownloadProgress(imageUrl, totalSize, downloaded));
}
Widget _octoErrorBuilder(
BuildContext context,
Object error,
StackTrace stackTrace,
) {
return errorWidget(context, imageUrl, error);
}
}

113
lib/widget/connect.dart Normal file
View file

@ -0,0 +1,113 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/bloc/app_password_exchange.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
class ConnectArguments {
ConnectArguments(this.account);
final Account account;
}
class Connect extends StatefulWidget {
static const routeName = "/connect";
Connect({
Key key,
@required this.account,
}) : super(key: key);
Connect.fromArgs(ConnectArguments args, {Key key})
: this(
key: key,
account: args.account,
);
@override
createState() => _ConnectState();
final Account account;
}
class _ConnectState extends State<Connect> {
@override
initState() {
super.initState();
_initBloc();
}
@override
build(BuildContext context) {
return AppTheme(
child: Scaffold(
body:
BlocListener<AppPasswordExchangeBloc, AppPasswordExchangeBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: Builder(builder: (context) => _buildContent(context)),
),
),
);
}
void _initBloc() {
_log.info("[_initBloc] Initialize bloc");
_connect();
}
Widget _buildContent(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.cloud,
size: 128,
color: Colors.blue,
),
Text(
AppLocalizations.of(context)
.connectingToServer(widget.account.url),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headline6,
)
],
),
),
);
}
void _onStateChange(
BuildContext context, AppPasswordExchangeBlocState state) {
if (state is AppPasswordExchangeBlocSuccess) {
final newAccount = widget.account.copyWith(password: state.password);
_log.info("[_onStateChange] Account is good: $newAccount");
Navigator.of(context).pop(newAccount);
} else if (state is AppPasswordExchangeBlocFailure) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(state.exception, context)),
duration: k.snackBarDurationNormal,
));
Navigator.of(context).pop(null);
}
}
void _connect() {
_bloc.add(AppPasswordExchangeBlocConnect(widget.account));
}
final _bloc = AppPasswordExchangeBloc();
static final _log = Logger("widget.connect._ConnectState");
}

121
lib/widget/home.dart Normal file
View file

@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:kiwi/kiwi.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/metadata_task_manager.dart';
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/widget/home_albums.dart';
import 'package:nc_photos/widget/home_photos.dart';
class HomeArguments {
HomeArguments(this.account);
final Account account;
}
class Home extends StatefulWidget {
static const routeName = "/home";
Home({
Key key,
@required this.account,
}) : super(key: key);
Home.fromArgs(HomeArguments args, {Key key})
: this(
account: args.account,
);
@override
createState() => _HomeState();
final Account account;
}
class _HomeState extends State<Home> {
@override
initState() {
super.initState();
if (Pref.inst().isEnableExif()) {
KiwiContainer()
.resolve<MetadataTaskManager>()
.addTask(MetadataTask(widget.account));
}
_pageController = PageController(initialPage: 0, keepPage: false);
}
@override
build(BuildContext context) {
return AppTheme(
child: Scaffold(
bottomNavigationBar: _buildBottomNavigationBar(context),
body: Builder(builder: (context) => _buildContent(context)),
),
);
}
Widget _buildBottomNavigationBar(BuildContext context) {
return BottomNavigationBar(
items: <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: const Icon(Icons.photo_outlined),
label: AppLocalizations.of(context).photosTabLabel,
),
BottomNavigationBarItem(
icon: const Icon(Icons.photo_album_outlined),
label: AppLocalizations.of(context).albumsTabLabel,
),
],
currentIndex: _nextPage,
onTap: _onTapNavItem,
);
}
Widget _buildContent(BuildContext context) {
return PageView.builder(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
itemCount: 2,
itemBuilder: _buildPage,
);
}
Widget _buildPage(BuildContext context, int index) {
switch (index) {
case 0:
return _buildPhotosPage(context);
case 1:
return _buildAlbumsPage(context);
default:
throw ArgumentError("Invalid page index: $index");
}
}
Widget _buildPhotosPage(BuildContext context) {
return HomePhotos(
account: widget.account,
);
}
Widget _buildAlbumsPage(BuildContext context) {
return HomeAlbums(
account: widget.account,
);
}
void _onTapNavItem(int index) {
_pageController.animateToPage(index,
duration: k.animationDurationNormal, curve: Curves.easeInOut);
setState(() {
_nextPage = index;
});
}
PageController _pageController;
int _nextPage = 0;
}

428
lib/widget/home_albums.dart Normal file
View file

@ -0,0 +1,428 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/bloc/list_album.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/remove.dart';
import 'package:nc_photos/widget/album_grid_item.dart';
import 'package:nc_photos/widget/album_viewer.dart';
import 'package:nc_photos/widget/home_app_bar.dart';
import 'package:nc_photos/widget/new_album_dialog.dart';
import 'package:tuple/tuple.dart';
class HomeAlbums extends StatefulWidget {
HomeAlbums({
Key key,
@required this.account,
}) : super(key: key);
@override
createState() => _HomeAlbumsState();
final Account account;
}
class _HomeAlbumsState extends State<HomeAlbums> {
@override
initState() {
super.initState();
_initBloc();
}
@override
build(BuildContext context) {
return BlocListener<ListAlbumBloc, ListAlbumBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: BlocBuilder<ListAlbumBloc, ListAlbumBlocState>(
bloc: _bloc,
builder: (context, state) => _buildContent(context, state),
),
);
}
void _initBloc() {
ListAlbumBloc bloc;
final blocId =
"${widget.account.scheme}://${widget.account.username}@${widget.account.address}";
try {
_log.fine("[_initBloc] Resolving bloc for '$blocId'");
bloc = KiwiContainer().resolve<ListAlbumBloc>("ListAlbumBloc($blocId)");
} catch (e) {
// no created instance for this account, make a new one
_log.info("[_initBloc] New bloc instance for account: ${widget.account}");
bloc = ListAlbumBloc();
KiwiContainer().registerInstance<ListAlbumBloc>(bloc,
name: "ListAlbumBloc($blocId)");
}
_bloc = bloc;
if (_bloc.state is ListAlbumBlocInit) {
_log.info("[_initBloc] Initialize bloc");
_reqQuery();
} else {
// process the current state
_onStateChange(context, _bloc.state);
}
}
Widget _buildContent(BuildContext context, ListAlbumBlocState state) {
return Stack(
children: [
Theme(
data: Theme.of(context).copyWith(
accentColor: AppTheme.getOverscrollIndicatorColor(context),
),
child: CustomScrollView(
slivers: [
_buildAppBar(context),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverStaggeredGrid.extentBuilder(
maxCrossAxisExtent: 256,
mainAxisSpacing: 8,
itemCount: _items.length + (_isSelectionMode ? 0 : 1),
itemBuilder: _buildItem,
staggeredTileBuilder: (index) {
return const StaggeredTile.count(1, 1);
},
),
),
],
),
),
if (state is ListAlbumBlocLoading)
Align(
alignment: Alignment.bottomCenter,
child: const LinearProgressIndicator(),
),
],
);
}
Widget _buildAppBar(BuildContext context) {
if (_isSelectionMode) {
return _buildSelectionAppBar(context);
} else {
return _buildNormalAppBar(context);
}
}
Widget _buildSelectionAppBar(BuildContext conetxt) {
return Theme(
data: Theme.of(context).copyWith(
appBarTheme: AppTheme.getContextualAppBarTheme(context),
),
child: SliverAppBar(
pinned: true,
leading: IconButton(
icon: const Icon(Icons.close),
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
onPressed: () {
setState(() {
_selectedItems.clear();
});
},
),
title: Text(AppLocalizations.of(context)
.selectionAppBarTitle(_selectedItems.length)),
actions: [
IconButton(
icon: const Icon(Icons.delete),
tooltip: AppLocalizations.of(context).deleteSelectedTooltip,
onPressed: () {
_onSelectionAppBarDeletePressed();
},
),
],
),
);
}
Widget _buildNormalAppBar(BuildContext context) {
return HomeSliverAppBar(
account: widget.account,
);
}
Widget _buildItem(BuildContext context, int index) {
if (index < _items.length) {
return _buildAlbumItem(context, index);
} else {
return _buildNewAlbumItem(context);
}
}
Widget _buildAlbumItem(BuildContext context, int index) {
final item = _items[index];
return AlbumGridItem(
cover: _buildAlbumCover(context, item.album),
title: item.album.name,
subtitle: AppLocalizations.of(context).albumSize(item.album.items.length),
isSelected: _selectedItems.contains(item),
onTap: () => _onItemTap(item),
onLongPress: _isSelectionMode ? null : () => _onItemLongPress(item),
);
}
Widget _buildAlbumCover(BuildContext context, Album album) {
Widget cover;
try {
// use the latest file as cover
final latestFile = album.items
.whereType<AlbumFileItem>()
.map((e) => e.file)
.where((element) => file_util.isSupportedFormat(element))
.sorted(compareFileDateTimeDescending)
.first;
String previewUrl;
if (latestFile.hasPreview) {
previewUrl = api_util.getFilePreviewUrl(widget.account, latestFile,
width: 512, height: 512);
} else {
previewUrl = api_util.getFileUrl(widget.account, latestFile);
}
cover = FittedBox(
clipBehavior: Clip.hardEdge,
fit: BoxFit.cover,
child: CachedNetworkImage(
imageUrl: previewUrl,
httpHeaders: {
"Authorization": Api.getAuthorizationHeaderValue(widget.account),
},
fadeInDuration: const Duration(),
filterQuality: FilterQuality.high,
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
),
);
} catch (_) {
cover = const Icon(
Icons.panorama,
color: Colors.white,
size: 96,
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Theme.of(context).brightness == Brightness.light
? Colors.black.withAlpha(40)
: Colors.white.withAlpha(40),
constraints: const BoxConstraints.expand(),
child: cover,
),
);
}
Widget _buildNewAlbumItem(BuildContext context) {
return AlbumGridItem(
cover: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Theme.of(context).brightness == Brightness.light
? Colors.black.withAlpha(40)
: Colors.white.withAlpha(40),
constraints: const BoxConstraints.expand(),
child: const Icon(
Icons.add,
color: Colors.white,
size: 96,
),
),
),
title: AppLocalizations.of(context).createAlbumTooltip,
onTap: () => _onNewAlbumItemTap(context),
);
}
void _onStateChange(BuildContext context, ListAlbumBlocState state) {
if (state is ListAlbumBlocInit) {
_items.clear();
} else if (state is ListAlbumBlocSuccess || state is ListAlbumBlocLoading) {
_transformItems(state.albums);
} else if (state is ListAlbumBlocFailure) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(state.exception, context)),
duration: k.snackBarDurationNormal,
));
} else if (state is ListAlbumBlocInconsistent) {
_reqQuery();
}
}
void _onItemTap(_GridItem item) {
if (_isSelectionMode) {
if (!_items.contains(item)) {
_log.warning("[_onItemTap] Item not found in backing list, ignoring");
return;
}
if (_selectedItems.contains(item)) {
// unselect
setState(() {
_selectedItems.remove(item);
});
} else {
// select
setState(() {
_selectedItems.add(item);
});
}
} else {
Navigator.of(context).pushNamed(AlbumViewer.routeName,
arguments: AlbumViewerArguments(widget.account, item.album));
}
}
void _onItemLongPress(_GridItem item) {
if (!_items.contains(item)) {
_log.warning(
"[_onItemLongPress] Item not found in backing list, ignoring");
return;
}
setState(() {
_selectedItems.add(item);
});
}
void _onNewAlbumItemTap(BuildContext context) {
showDialog(
context: context,
builder: (_) => NewAlbumDialog(
account: widget.account,
),
).catchError((e, stacktrace) {
_log.severe(
"[_onNewAlbumItemTap] Failed while showDialog", e, stacktrace);
SnackBarManager().showSnackBar(SnackBar(
content:
Text(AppLocalizations.of(context).createAlbumFailureNotification),
duration: k.snackBarDurationNormal,
));
});
}
Future<void> _onSelectionAppBarDeletePressed() async {
SnackBarManager().showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)
.deleteSelectedProcessingNotification(_selectedItems.length)),
duration: k.snackBarDurationShort,
));
final selectedFiles = _selectedItems.map((e) => e.album.albumFile).toList();
setState(() {
_selectedItems.clear();
});
final fileRepo = FileRepo(FileCachedDataSource());
final albumRepo = AlbumRepo(AlbumCachedDataSource());
final failures = <File>[];
for (final f in selectedFiles) {
try {
await Remove(fileRepo, albumRepo)(widget.account, f);
} catch (e, stacktrace) {
_log.severe(
"[_onSelectionAppBarDeletePressed] Failed while removing file: ${f.path}",
e,
stacktrace);
failures.add(f);
}
}
if (failures.isEmpty) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(
AppLocalizations.of(context).deleteSelectedSuccessNotification),
duration: k.snackBarDurationNormal,
));
} else {
SnackBarManager().showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)
.deleteSelectedFailureNotification(failures.length)),
duration: k.snackBarDurationNormal,
));
}
}
/// Transform an Album list to grid items
void _transformItems(List<Album> albums) {
final sortedAlbums = albums.map((e) {
// find the latest file in this album
try {
return Tuple2(
e.items
.whereType<AlbumFileItem>()
.map((e) => e.file)
.where((element) => file_util.isSupportedFormat(element))
.sorted(compareFileDateTimeDescending)
.first
.lastModified,
e);
} catch (_) {
return Tuple2(e.lastUpdated, e);
}
}).sorted((a, b) {
// then sort in descending order
final tmp = b.item1.compareTo(a.item1);
if (tmp != 0) {
return tmp;
} else {
return a.item2.name.compareTo(b.item2.name);
}
}).map((e) => e.item2);
_items.clear();
_items.addAll(sortedAlbums.map((e) => _GridItem(e)));
_transformSelectedItems();
}
/// Map selected items to the new item list
void _transformSelectedItems() {
final newSelectedItems = _selectedItems
.map((from) {
try {
return _items.whereType<_GridItem>().firstWhere(
(to) => from.album.albumFile.path == to.album.albumFile.path);
} catch (_) {
return null;
}
})
.where((element) => element != null)
.toList();
_selectedItems
..clear()
..addAll(newSelectedItems);
}
void _reqQuery() {
_bloc.add(ListAlbumBlocQuery(widget.account));
}
bool get _isSelectionMode => _selectedItems.isNotEmpty;
ListAlbumBloc _bloc;
final _items = <_GridItem>[];
final _selectedItems = <_GridItem>[];
static final _log = Logger("widget.home_albums._HomeAlbumsState");
}
class _GridItem {
_GridItem(this.album);
Album album;
}

View file

@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/widget/account_picker_dialog.dart';
import 'package:nc_photos/widget/settings.dart';
/// AppBar for home screens
class HomeSliverAppBar extends StatelessWidget {
HomeSliverAppBar({
Key key,
@required this.account,
this.actions,
this.menuActions,
this.onSelectedMenuActions,
}) : super(key: key);
@override
build(BuildContext context) {
return SliverAppBar(
title: InkWell(
onTap: () {
showDialog(
context: context,
builder: (_) => AccountPickerDialog(
account: account,
),
);
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Icon(
Icons.cloud,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
account.url,
style: const TextStyle(fontSize: 16),
),
Text(
account.username,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
),
],
),
),
),
floating: true,
automaticallyImplyLeading: false,
actions: (actions ?? []) +
[
PopupMenuButton(
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
itemBuilder: (context) =>
(menuActions ?? []) +
[
PopupMenuItem(
value: _menuValueAbout,
child:
Text(AppLocalizations.of(context).settingsMenuLabel),
),
],
onSelected: (option) {
if (option >= 0) {
onSelectedMenuActions?.call(option);
} else {
if (option == _menuValueAbout) {
Navigator.of(context).pushNamed(Settings.routeName,
arguments: SettingsArguments(account));
}
}
},
),
],
);
}
final Account account;
/// Screen specific action buttons
final List<Widget> actions;
/// Screen specific actions under the overflow menu. The value of each item
/// much >= 0
final List<PopupMenuEntry<int>> menuActions;
final void Function(int) onSelectedMenuActions;
static const _menuValueAbout = -1;
}

527
lib/widget/home_photos.dart Normal file
View file

@ -0,0 +1,527 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:intl/intl.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/bloc/scan_dir.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/remove.dart';
import 'package:nc_photos/use_case/update_album.dart';
import 'package:nc_photos/widget/album_picker_dialog.dart';
import 'package:nc_photos/widget/home_app_bar.dart';
import 'package:nc_photos/widget/image_grid_item.dart';
import 'package:nc_photos/widget/popup_menu_zoom.dart';
import 'package:nc_photos/widget/viewer.dart';
class HomePhotos extends StatefulWidget {
HomePhotos({
Key key,
@required this.account,
}) : super(key: key);
@override
createState() => _HomePhotosState();
final Account account;
}
class _HomePhotosState extends State<HomePhotos> {
@override
initState() {
super.initState();
_initBloc();
_thumbZoomLevel = Pref.inst().getHomePhotosZoomLevel(0);
}
@override
build(BuildContext context) {
return BlocListener<ScanDirBloc, ScanDirBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: BlocBuilder<ScanDirBloc, ScanDirBlocState>(
bloc: _bloc,
builder: (context, state) => _buildContent(context, state),
),
);
}
void _initBloc() {
ScanDirBloc bloc;
final blocId =
"${widget.account.scheme}://${widget.account.username}@${widget.account.address}?${widget.account.roots.join('&')}";
try {
_log.fine("[_initBloc] Resolving bloc for '$blocId'");
bloc = KiwiContainer().resolve<ScanDirBloc>("ScanDirBloc($blocId)");
} catch (e) {
// no created instance for this account, make a new one
_log.info("[_initBloc] New bloc instance for account: ${widget.account}");
bloc = ScanDirBloc();
KiwiContainer()
.registerInstance<ScanDirBloc>(bloc, name: "ScanDirBloc($blocId)");
}
_bloc = bloc;
if (_bloc.state is ScanDirBlocInit) {
_log.info("[_initBloc] Initialize bloc");
_reqQuery(widget.account.roots);
} else {
// process the current state
_onStateChange(context, _bloc.state);
}
}
Widget _buildContent(BuildContext context, ScanDirBlocState state) {
return Stack(
children: [
Theme(
data: Theme.of(context).copyWith(
accentColor: AppTheme.getOverscrollIndicatorColor(context),
),
child: CustomScrollView(
slivers: [
_buildAppBar(context),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverStaggeredGrid.extentBuilder(
// need to rebuild grid after zoom level changed
key: ValueKey(_thumbZoomLevel),
maxCrossAxisExtent: _thumbSize.toDouble(),
itemCount: _items.length,
itemBuilder: _buildItem,
staggeredTileBuilder: (index) {
if (_items[index] is _GridSubtitleItem) {
return const StaggeredTile.extent(99, 32);
} else {
return const StaggeredTile.count(1, 1);
}
},
),
),
],
),
),
if (state is ScanDirBlocLoading)
Align(
alignment: Alignment.bottomCenter,
child: const LinearProgressIndicator(),
),
],
);
}
Widget _buildAppBar(BuildContext context) {
if (_isSelectionMode) {
return _buildSelectionAppBar(context);
} else {
return _buildNormalAppBar(context);
}
}
Widget _buildSelectionAppBar(BuildContext conetxt) {
return Theme(
data: Theme.of(context).copyWith(
appBarTheme: AppTheme.getContextualAppBarTheme(context),
),
child: SliverAppBar(
pinned: true,
leading: IconButton(
icon: const Icon(Icons.close),
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
onPressed: () {
setState(() {
_selectedItems.clear();
});
},
),
title: Text(AppLocalizations.of(context)
.selectionAppBarTitle(_selectedItems.length)),
actions: [
IconButton(
icon: const Icon(Icons.playlist_add),
tooltip: AppLocalizations.of(context).addSelectedToAlbumTooltip,
onPressed: () {
_onSelectionAppBarAddToAlbumPressed(context);
},
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: AppLocalizations.of(context).deleteSelectedTooltip,
onPressed: () {
_onSelectionAppBarDeletePressed(context);
},
),
],
),
);
}
Widget _buildNormalAppBar(BuildContext context) {
return HomeSliverAppBar(
account: widget.account,
actions: [
PopupMenuButton(
icon: const Icon(Icons.zoom_in),
tooltip: AppLocalizations.of(context).zoomTooltip,
itemBuilder: (context) => [
PopupMenuZoom(
initialValue: _thumbZoomLevel,
onChanged: (value) {
setState(() {
_thumbZoomLevel = value.round();
});
Pref.inst().setHomePhotosZoomLevel(_thumbZoomLevel);
},
),
],
),
],
);
}
Widget _buildItem(BuildContext context, int index) {
final item = _items[index];
if (item is _GridSubtitleItem) {
return _buildSubtitleItem(context, item, index);
} else if (item is _GridImageItem) {
return _buildImageItem(context, item, index);
} else {
_log.severe("[_buildItem] Unsupported item type: ${item.runtimeType}");
throw StateError("Unsupported item type: ${item.runtimeType}");
}
}
Widget _buildSubtitleItem(
BuildContext context, _GridSubtitleItem item, int index) {
return Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
item.subtitle,
style: Theme.of(context).textTheme.caption.copyWith(
color: Theme.of(context).textTheme.bodyText1.color,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _buildImageItem(BuildContext context, _GridImageItem item, int index) {
return ImageGridItem(
account: widget.account,
imageUrl: item.previewUrl,
isSelected: _selectedItems.contains(item),
onTap: () => _onItemTap(item, index),
onLongPress:
_isSelectionMode ? null : () => _onItemLongPress(item, index),
);
}
void _onStateChange(BuildContext context, ScanDirBlocState state) {
if (state is ScanDirBlocInit) {
_items.clear();
} else if (state is ScanDirBlocSuccess || state is ScanDirBlocLoading) {
_transformItems(state.files);
} else if (state is ScanDirBlocFailure) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(state.exception, context)),
duration: k.snackBarDurationNormal,
));
} else if (state is ScanDirBlocInconsistent) {
_reqQuery(widget.account.roots);
}
}
void _onItemTap(_GridFileItem item, int index) {
if (_isSelectionMode) {
if (!_items.contains(item)) {
_log.warning("[_onItemTap] Item not found in backing list, ignoring");
return;
}
if (_selectedItems.contains(item)) {
// unselect
setState(() {
_selectedItems.remove(item);
});
} else {
// select
setState(() {
_selectedItems.add(item);
});
}
} else {
final fileIndex = _itemIndexToFileIndex(index);
Navigator.pushNamed(context, Viewer.routeName,
arguments: ViewerArguments(widget.account, _backingFiles, fileIndex));
}
}
void _onItemLongPress(_GridFileItem item, int index) {
if (!_items.contains(item)) {
_log.warning(
"[_onItemLongPress] Item not found in backing list, ignoring");
return;
}
setState(() {
_selectedItems.add(item);
});
}
void _onSelectionAppBarAddToAlbumPressed(BuildContext context) {
showDialog(
context: context,
builder: (_) => AlbumPickerDialog(
account: widget.account,
),
).then((value) {
if (value == null) {
// user cancelled the dialog
} else if (value is Album) {
_log.info("[_onSelectionAppBarAddToAlbumPressed] Album picked: $value");
_addSelectedToAlbum(context, value).then((_) {
setState(() {
_selectedItems.clear();
});
SnackBarManager().showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)
.addSelectedToAlbumSuccessNotification(value.name)),
duration: k.snackBarDurationNormal,
));
}).catchError((_) {});
} else {
SnackBarManager().showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)
.addSelectedToAlbumFailureNotification),
duration: k.snackBarDurationNormal,
));
}
}).catchError((e, stacktrace) {
_log.severe(
"[_onSelectionAppBarAddToAlbumPressed] Failed while showDialog",
e,
stacktrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(
"${AppLocalizations.of(context).addSelectedToAlbumFailureNotification}: "
"${exception_util.toUserString(e, context)}"),
duration: k.snackBarDurationNormal,
));
});
}
Future<void> _addSelectedToAlbum(BuildContext context, Album album) async {
final selectedItems = _selectedItems
.whereType<_GridFileItem>()
.map((e) => AlbumFileItem(file: e.file))
.toList();
try {
final albumRepo = AlbumRepo(AlbumCachedDataSource());
await UpdateAlbum(albumRepo)(
widget.account,
album.copyWith(
items: [
...album.items,
...selectedItems,
],
));
} catch (e, stacktrace) {
_log.severe(
"[_addSelectedToAlbum] Failed while updating album", e, stacktrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(
"${AppLocalizations.of(context).addSelectedToAlbumFailureNotification}: "
"${exception_util.toUserString(e, context)}"),
duration: k.snackBarDurationNormal,
));
rethrow;
}
}
Future<void> _onSelectionAppBarDeletePressed(BuildContext context) async {
SnackBarManager().showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)
.deleteSelectedProcessingNotification(_selectedItems.length)),
duration: k.snackBarDurationShort,
));
final selectedFiles =
_selectedItems.whereType<_GridFileItem>().map((e) => e.file).toList();
setState(() {
_selectedItems.clear();
});
final fileRepo = FileRepo(FileCachedDataSource());
final albumRepo = AlbumRepo(AlbumCachedDataSource());
final failures = <File>[];
for (final f in selectedFiles) {
try {
await Remove(fileRepo, albumRepo)(widget.account, f);
} catch (e, stacktrace) {
_log.severe(
"[_onSelectionAppBarDeletePressed] Failed while removing file: ${f.path}",
e,
stacktrace);
failures.add(f);
}
}
if (failures.isEmpty) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(
AppLocalizations.of(context).deleteSelectedSuccessNotification),
duration: k.snackBarDurationNormal,
));
} else {
SnackBarManager().showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)
.deleteSelectedFailureNotification(failures.length)),
duration: k.snackBarDurationNormal,
));
}
}
/// Transform a File list to grid items
void _transformItems(List<File> files) {
_backingFiles = files
.where((element) => file_util.isSupportedFormat(element))
.sorted(compareFileDateTimeDescending);
_items.clear();
String currentDateStr;
for (final f in _backingFiles) {
final newDateStr = (f.metadata?.exif?.dateTimeOriginal ?? f.lastModified)
?.toSubtitleString();
if (newDateStr != currentDateStr) {
_items.add(_GridItem.subtitle(newDateStr));
currentDateStr = newDateStr;
}
var previewUrl;
if (f.hasPreview) {
previewUrl = api_util.getFilePreviewUrl(widget.account, f,
width: _thumbSize, height: _thumbSize);
} else {
previewUrl = api_util.getFileUrl(widget.account, f);
}
_items.add(_GridItem.image(f, previewUrl));
}
_transformSelectedItems();
}
/// Map selected items to the new item list
void _transformSelectedItems() {
final newSelectedItems = _selectedItems
.map((from) {
try {
return _items
.whereType<_GridFileItem>()
.firstWhere((to) => from.file.path == to.file.path);
} catch (_) {
return null;
}
})
.where((element) => element != null)
.toList();
_selectedItems
..clear()
..addAll(newSelectedItems);
}
/// Convert a grid item index to its corresponding file index
///
/// These two indices differ when there's non file-based item on screen
int _itemIndexToFileIndex(int itemIndex) {
var fileIndex = 0;
final itemIt = _items.iterator;
for (int i = 0; i < itemIndex; ++i) {
if (!itemIt.moveNext()) {
// ???
break;
}
if (itemIt.current is _GridFileItem) {
++fileIndex;
}
}
return fileIndex;
}
void _reqQuery(List<String> roots) {
_bloc.add(ScanDirBlocQuery(
widget.account,
roots
.map((e) => File(
path:
"${api_util.getWebdavRootUrlRelative(widget.account)}/$e"))
.toList()));
}
int get _thumbSize {
switch (_thumbZoomLevel) {
case 1:
return 176;
case 2:
return 256;
case 0:
default:
return 112;
}
}
bool get _isSelectionMode => _selectedItems.isNotEmpty;
ScanDirBloc _bloc;
final _items = <_GridItem>[];
var _backingFiles = <File>[];
var _thumbZoomLevel = 0;
final _selectedItems = <_GridFileItem>[];
static final _log = Logger("widget.home_photos._HomePhotosState");
}
abstract class _GridItem {
const _GridItem();
factory _GridItem.subtitle(String val) => _GridSubtitleItem(val);
factory _GridItem.image(File file, String previewUrl) =>
_GridImageItem(file, previewUrl);
}
class _GridSubtitleItem extends _GridItem {
_GridSubtitleItem(this.subtitle);
final String subtitle;
}
abstract class _GridFileItem extends _GridItem {
_GridFileItem(this.file);
final File file;
}
class _GridImageItem extends _GridFileItem {
_GridImageItem(File file, this.previewUrl) : super(file);
final String previewUrl;
}
extension on DateTime {
String toSubtitleString() {
final format = DateFormat(DateFormat.YEAR_MONTH_DAY);
return format.format(this.toLocal());
}
}

View file

@ -0,0 +1,74 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/theme.dart';
class ImageGridItem extends StatelessWidget {
ImageGridItem({
Key key,
@required this.account,
@required this.imageUrl,
this.isSelected = false,
this.onTap,
this.onLongPress,
}) : super(key: key);
@override
build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
Padding(
padding: const EdgeInsets.all(2),
child: FittedBox(
clipBehavior: Clip.hardEdge,
fit: BoxFit.cover,
child: CachedNetworkImage(
imageUrl: imageUrl,
httpHeaders: {
"Authorization": Api.getAuthorizationHeaderValue(account),
},
fadeInDuration: const Duration(),
filterQuality: FilterQuality.high,
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
),
),
),
if (isSelected)
Positioned.fill(
child: Container(
color: AppTheme.getSelectionOverlayColor(context),
),
),
if (isSelected)
Positioned.fill(
child: Align(
alignment: Alignment.center,
child: Icon(
Icons.check_circle_outlined,
size: 32,
color: AppTheme.getSelectionCheckColor(context),
),
),
),
Positioned.fill(
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
),
),
)
],
);
}
final Account account;
final String imageUrl;
final bool isSelected;
final VoidCallback onTap;
final VoidCallback onLongPress;
}

169
lib/widget/my_app.dart Normal file
View file

@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/widget/album_viewer.dart';
import 'package:nc_photos/widget/connect.dart';
import 'package:nc_photos/widget/home.dart';
import 'package:nc_photos/widget/root_picker.dart';
import 'package:nc_photos/widget/settings.dart';
import 'package:nc_photos/widget/setup.dart';
import 'package:nc_photos/widget/sign_in.dart';
import 'package:nc_photos/widget/splash.dart';
import 'package:nc_photos/widget/viewer.dart';
abstract class MyApp extends StatelessWidget implements SnackBarHandler {
MyApp() {
SnackBarManager().registerHandler(this);
}
@override
build(BuildContext context) {
return MaterialApp(
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
theme: _getLightTheme(),
// darkTheme: _getDarkTheme(),
initialRoute: Splash.routeName,
onGenerateRoute: _onGenerateRoute,
scaffoldMessengerKey: _scaffoldMessengerKey,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
debugShowCheckedModeBanner: false,
);
}
@override
showSnackBar(SnackBar snackBar) =>
_scaffoldMessengerKey.currentState?.showSnackBar(snackBar);
ThemeData _getLightTheme() => ThemeData(
brightness: Brightness.light,
primarySwatch: AppTheme.primarySwatchLight,
);
// ThemeData _getDarkTheme() => ThemeData(
// brightness: Brightness.dark,
// primarySwatch: AppTheme.primarySwatchDark,
// );
Map<String, WidgetBuilder> _getRouter() => {
Setup.routeName: (context) => Setup(),
SignIn.routeName: (context) => SignIn(),
Splash.routeName: (context) => Splash(),
};
Route<dynamic> _onGenerateRoute(RouteSettings settings) {
_log.info("[_onGenerateRoute] Route: ${settings.name}");
Route<dynamic> route;
route ??= _handleBasicRoute(settings);
route ??= _handleViewerRoute(settings);
route ??= _handleConnectRoute(settings);
route ??= _handleHomeRoute(settings);
route ??= _handleRootPickerRoute(settings);
route ??= _handleAlbumViewerRoute(settings);
route ??= _handleSettingsRoute(settings);
return route;
}
Route<dynamic> _handleBasicRoute(RouteSettings settings) {
for (final e in _getRouter().entries) {
if (e.key == settings.name) {
return MaterialPageRoute(
builder: e.value,
);
}
}
return null;
}
Route<dynamic> _handleViewerRoute(RouteSettings settings) {
try {
if (settings.name == Viewer.routeName && settings.arguments != null) {
final ViewerArguments args = settings.arguments;
return MaterialPageRoute(
builder: (context) => Viewer.fromArgs(args),
);
}
} catch (e) {
_log.severe("[_handleViewerRoute] Failed while handling route", e);
}
return null;
}
Route<dynamic> _handleConnectRoute(RouteSettings settings) {
try {
if (settings.name == Connect.routeName && settings.arguments != null) {
final ConnectArguments args = settings.arguments;
return MaterialPageRoute(
builder: (context) => Connect.fromArgs(args),
);
}
} catch (e) {
_log.severe("[_handleConnectRoute] Failed while handling route", e);
}
return null;
}
Route<dynamic> _handleHomeRoute(RouteSettings settings) {
try {
if (settings.name == Home.routeName && settings.arguments != null) {
final HomeArguments args = settings.arguments;
return MaterialPageRoute(
builder: (context) => Home.fromArgs(args),
);
}
} catch (e) {
_log.severe("[_handleHomeRoute] Failed while handling route", e);
}
return null;
}
Route<dynamic> _handleRootPickerRoute(RouteSettings settings) {
try {
if (settings.name == RootPicker.routeName && settings.arguments != null) {
final RootPickerArguments args = settings.arguments;
return MaterialPageRoute(
builder: (context) => RootPicker.fromArgs(args),
);
}
} catch (e) {
_log.severe("[_handleRootPickerRoute] Failed while handling route", e);
}
return null;
}
Route<dynamic> _handleAlbumViewerRoute(RouteSettings settings) {
try {
if (settings.name == AlbumViewer.routeName &&
settings.arguments != null) {
final AlbumViewerArguments args = settings.arguments;
return MaterialPageRoute(
builder: (context) => AlbumViewer.fromArgs(args),
);
}
} catch (e) {
_log.severe("[_handleAlbumViewerRoute] Failed while handling route", e);
}
return null;
}
Route<dynamic> _handleSettingsRoute(RouteSettings settings) {
try {
if (settings.name == Settings.routeName && settings.arguments != null) {
final SettingsArguments args = settings.arguments;
return MaterialPageRoute(
builder: (context) => Settings.fromArgs(args),
);
}
} catch (e) {
_log.severe("[_handleSettingsRoute] Failed while handling route", e);
}
return null;
}
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
static final _log = Logger("widget.my_app.MyApp");
}

View file

@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/use_case/create_album.dart';
/// Dialog to create a new album
///
/// The created album will be popped to the previous route, or null if user
/// cancelled
class NewAlbumDialog extends StatefulWidget {
NewAlbumDialog({
Key key,
@required this.account,
}) : super(key: key);
@override
createState() => _NewAlbumDialogState();
final Account account;
}
class _NewAlbumDialogState extends State<NewAlbumDialog> {
@override
initState() {
super.initState();
}
@override
build(BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context).createAlbumTooltip),
content: Form(
key: _formKey,
child: TextFormField(
decoration: InputDecoration(
hintText: AppLocalizations.of(context).nameInputHint,
),
validator: (value) {
if (value.isEmpty) {
return AppLocalizations.of(context).albumNameInputInvalidEmpty;
}
return null;
},
onSaved: (value) {
_formValue.name = value;
},
),
),
actions: [
TextButton(
onPressed: () => _onOkPressed(context),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
);
}
void _onOkPressed(BuildContext context) {
if (_formKey.currentState.validate()) {
_formKey.currentState.save();
final album = Album(
name: _formValue.name,
items: const [],
);
_log.info("[_onOkPressed] Creating album: $album");
final albumRepo = AlbumRepo(AlbumCachedDataSource());
final newAlbum = CreateAlbum(albumRepo)(widget.account, album);
// let previous route to handle this future
Navigator.of(context).pop(newAlbum);
}
}
final _formKey = GlobalKey<FormState>();
final _formValue = _FormValue();
static final _log = Logger("widget.new_album_dialog._AlbumPickerDialogState");
}
class _FormValue {
String name;
}

View file

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
class PopupMenuZoom extends PopupMenuEntry<void> {
PopupMenuZoom({
Key key,
@required this.initialValue,
this.onChanged,
}) : super(key: key);
@override
represents(void value) => false;
@override
createState() => _PopupMenuZoomState();
@override
// this value doesn't seems to do anything?
final double height = 48.0;
final int initialValue;
final void Function(double) onChanged;
}
class _PopupMenuZoomState extends State<PopupMenuZoom> {
@override
initState() {
super.initState();
_value = widget.initialValue.toDouble();
}
@override
build(BuildContext context) {
return Slider(
value: _value,
min: 0,
max: 2,
divisions: 2,
onChanged: (value) {
setState(() {
_value = value;
});
widget.onChanged?.call(value);
},
);
}
var _value = 0.0;
}

439
lib/widget/root_picker.dart Normal file
View file

@ -0,0 +1,439 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/bloc/ls_dir.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:path/path.dart' as path;
class RootPickerArguments {
RootPickerArguments(this.account);
final Account account;
}
class RootPicker extends StatefulWidget {
static const routeName = "/root-picker";
RootPicker({
Key key,
@required this.account,
}) : super(key: key);
RootPicker.fromArgs(RootPickerArguments args, {Key key})
: this(
key: key,
account: args.account,
);
@override
createState() => _RootPickerState();
final Account account;
}
class _RootPickerState extends State<RootPicker> {
@override
initState() {
super.initState();
_initBloc();
}
@override
build(BuildContext context) {
return AppTheme(
child: Scaffold(
body: BlocListener<LsDirBloc, LsDirBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: BlocBuilder<LsDirBloc, LsDirBlocState>(
bloc: _bloc,
builder: (context, state) => _buildContent(context, state),
),
),
),
);
}
void _initBloc() {
_log.info("[_initBloc] Initialize bloc");
_bloc = LsDirBloc();
_bloc.add(LsDirBlocQuery(widget.account,
[File(path: api_util.getWebdavRootUrlRelative(widget.account))]));
}
Widget _buildContent(BuildContext context, LsDirBlocState state) {
return SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Text(
AppLocalizations.of(context).rootPickerHeaderText,
style: Theme.of(context).textTheme.headline5,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.topStart,
child: Text(
AppLocalizations.of(context).rootPickerSubHeaderText,
),
),
],
),
),
if (state is LsDirBlocLoading)
Align(
alignment: Alignment.topCenter,
child: const LinearProgressIndicator(),
),
Expanded(
child: Align(
alignment: Alignment.center,
child: Container(
constraints: const BoxConstraints(
maxWidth: AppTheme.widthLimitedContentMaxWidth),
// needed otherwise no ripple effect
child: _buildList(context),
),
),
),
Container(
constraints: const BoxConstraints(
maxWidth: AppTheme.widthLimitedContentMaxWidth),
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (!ModalRoute.of(context).isFirst)
TextButton(
onPressed: () => _onSkipPressed(context),
child: Text(AppLocalizations.of(context).skipButtonLabel),
)
else
Container(),
ElevatedButton(
onPressed: () => _onConfirmPressed(context),
child: Text(AppLocalizations.of(context).confirmButtonLabel),
),
],
),
),
],
),
);
}
Widget _buildList(BuildContext context) {
final current = _findCurrentNavigateLevel();
final isTopLevel = _positions.isEmpty;
return Theme(
data: Theme.of(context).copyWith(
accentColor: AppTheme.getOverscrollIndicatorColor(context),
),
child: AnimatedSwitcher(
duration: k.animationDurationNormal,
// see AnimatedSwitcher.defaultLayoutBuilder
layoutBuilder: (currentChild, previousChildren) => Stack(
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
alignment: Alignment.topLeft,
),
child: ListView.separated(
key: ObjectKey(current),
itemBuilder: (context, index) {
if (!isTopLevel && index == 0) {
return ListTile(
dense: true,
leading: const SizedBox(width: 24),
title: Text(
AppLocalizations.of(context).rootPickerNavigateUpItemText),
onTap: () {
try {
_navigateUp();
} catch (e) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(e, context)),
duration: k.snackBarDurationNormal,
));
}
},
);
} else {
return _buildItem(context, current[index - (isTopLevel ? 0 : 1)]);
}
},
separatorBuilder: (context, index) => const Divider(),
itemCount: current.length + (isTopLevel ? 0 : 1),
),
),
);
}
Widget _buildItem(BuildContext context, LsDirBlocItem item) {
final pickState = _isItemPicked(item);
IconData iconData;
switch (pickState) {
case PickState.picked:
iconData = Icons.check_box;
break;
case PickState.childPicked:
iconData = Icons.indeterminate_check_box;
break;
case PickState.notPicked:
default:
iconData = Icons.check_box_outline_blank;
break;
}
return ListTile(
dense: true,
leading: IconButton(
icon: AnimatedSwitcher(
duration: k.animationDurationShort,
transitionBuilder: (child, animation) =>
ScaleTransition(child: child, scale: animation),
child: Icon(
iconData,
key: ValueKey(pickState),
),
),
onPressed: () {
if (pickState == PickState.picked) {
_unpick(item);
} else {
_pick(item);
}
},
),
title: Text(path.basename(item.file.path)),
trailing:
item.children.isNotEmpty ? const Icon(Icons.arrow_forward_ios) : null,
onTap: item.children.isNotEmpty
? () {
try {
_navigateInto(item.file);
} catch (e) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(e, context)),
duration: k.snackBarDurationNormal,
));
}
}
: null,
);
}
void _onStateChange(BuildContext context, LsDirBlocState state) {
if (state is LsDirBlocSuccess) {
_positions = [];
_root = LsDirBlocItem(File(path: "/"), state.items);
} else if (state is LsDirBlocFailure) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(state.exception, context)),
duration: k.snackBarDurationNormal,
));
}
}
void _onSkipPressed(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
content: Text(AppLocalizations.of(context)
.rootPickerSkipConfirmationDialogContent),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child:
Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
)).then((value) {
if (value == true) {
// default is to include all files, so we just return the same account
Navigator.of(context).pop(widget.account);
}
});
}
void _onConfirmPressed(BuildContext context) {
final roots = _picks.map((e) => e.file.strippedPath).toList();
final newAccount = widget.account.copyWith(roots: roots);
_log.info("[_onConfirmPressed] Account is good: $newAccount");
Navigator.of(context).pop(newAccount);
}
/// Pick an item
void _pick(LsDirBlocItem item) {
setState(() {
_picks.add(item);
_picks = _optimizePicks(_root);
});
_log.fine("[_pick] Picked: ${_pickListToString(_picks)}");
}
/// Optimize the picked array
///
/// 1) If a parent directory is picked, all children will be ignored
List<LsDirBlocItem> _optimizePicks(LsDirBlocItem item) {
if (_picks.contains(item)) {
// this dir is explicitly picked, nothing more to do
return [item];
}
if (item.children.isEmpty) {
return [];
}
final products = <LsDirBlocItem>[];
for (final i in item.children) {
products.addAll(_optimizePicks(i));
}
// // see if all children are being picked
// if (item != _root &&
// products.length >= item.children.length &&
// item.children.every((element) => products.contains(element))) {
// // all children are being picked, add [item] to list and remove its
// // children
// _log.fine(
// "[_optimizePicks] All children under '${item.file.path}' are being picked, optimized");
// return products
// .where((element) => !item.children.contains(element))
// .toList()
// ..add(item);
// }
return products;
}
/// Unpick an item
void _unpick(LsDirBlocItem item) {
setState(() {
if (_picks.contains(item)) {
// ourself is being picked, simple
_picks = _picks.where((element) => element != item).toList();
} else {
// Look for the closest picked dir
final parents = _picks
.where((element) => item.file.path.startsWith(element.file.path))
.toList()
..sort(
(a, b) => b.file.path.length.compareTo(a.file.path.length));
final parent = parents.first;
_picks.remove(parent);
_picks.addAll(_pickedAllExclude(parent, item));
}
});
_log.fine("[_unpick] Picked: ${_pickListToString(_picks)}");
}
/// Return a list where all children of [item] but [exclude] are picked
List<LsDirBlocItem> _pickedAllExclude(
LsDirBlocItem item, LsDirBlocItem exclude) {
if (item == exclude) {
return [];
}
_log.fine(
"[_pickedAllExclude] Unpicking '${item.file.path}' and picking children");
final products = <LsDirBlocItem>[];
for (final i in item.children) {
if (exclude.file.path.startsWith(i.file.path)) {
// [i] is a parent of exclude
products.addAll(_pickedAllExclude(i, exclude));
} else {
products.add(i);
}
}
return products;
}
PickState _isItemPicked(LsDirBlocItem item) {
var product = PickState.notPicked;
for (final p in _picks) {
// exact match, or parent is picked
if (p.file.path == item.file.path ||
item.file.path.startsWith("${p.file.path}/")) {
product = PickState.picked;
// no need to check the remaining ones
break;
}
if (p.file.path.startsWith("${item.file.path}/")) {
product = PickState.childPicked;
}
}
if (product == PickState.childPicked) {}
return product;
}
/// Return the string representation of a list of LsDirBlocItem
static _pickListToString(List<LsDirBlocItem> items) =>
"['${items.map((e) => e.file.path).join('\', \'')}']";
void _navigateInto(File file) {
final current = _findCurrentNavigateLevel();
final navPosition =
current.indexWhere((element) => element.file.path == file.path);
if (navPosition == -1) {
_log.severe("[_navigateInto] File not found: '${file.path}', "
"current level: ['${current.map((e) => e.file.path).join('\', \'')}']");
throw StateError("Can't navigate into directory");
}
setState(() {
_positions.add(navPosition);
});
}
void _navigateUp() {
if (_positions.isEmpty) {
throw StateError("Can't navigate up in the root directory");
}
setState(() {
_positions.removeLast();
});
}
/// Find and return the list of items currently navigated to
List<LsDirBlocItem> _findCurrentNavigateLevel() {
var product = _root.children;
for (final i in _positions) {
product = product[i].children;
}
return product;
}
LsDirBloc _bloc;
var _root = LsDirBlocItem(File(path: "/"), const []);
/// Track where the user is navigating in [_backingFiles]
var _positions = <int>[];
var _picks = <LsDirBlocItem>[];
static final _log = Logger("widget.root_picker._RootPickerState");
}
enum PickState {
notPicked,
picked,
childPicked,
}

187
lib/widget/settings.dart Normal file
View file

@ -0,0 +1,187 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/metadata_task_manager.dart';
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:url_launcher/url_launcher.dart';
class SettingsArguments {
SettingsArguments(this.account);
final Account account;
}
class Settings extends StatefulWidget {
static const routeName = "/settings";
Settings({
Key key,
@required this.account,
}) : super(key: key);
Settings.fromArgs(SettingsArguments args, {Key key})
: this(
account: args.account,
);
@override
createState() => _SettingsState();
final Account account;
}
class _SettingsState extends State<Settings> {
@override
initState() {
super.initState();
_isEnableExif = Pref.inst().isEnableExif();
}
@override
build(context) {
return AppTheme(
child: Scaffold(
body: Builder(
builder: (context) => _buildContent(context),
),
),
);
}
Widget _buildContent(BuildContext context) {
final translator = AppLocalizations.of(context).translator;
return CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
title: Text(AppLocalizations.of(context).settingsWidgetTitle),
),
SliverList(
delegate: SliverChildListDelegate(
[
SwitchListTile(
title:
Text(AppLocalizations.of(context).settingsExifSupportTitle),
subtitle: _isEnableExif
? Text(AppLocalizations.of(context)
.settingsExifSupportTrueSubtitle)
: null,
value: _isEnableExif,
onChanged: (value) => _onExifSupportChanged(context, value),
),
_buildCaption(context,
AppLocalizations.of(context).settingsAboutSectionTitle),
ListTile(
title: Text(AppLocalizations.of(context).settingsVersionTitle),
subtitle: const Text(k.version),
),
ListTile(
title:
Text(AppLocalizations.of(context).settingsSourceCodeTitle),
subtitle: Text(_sourceRepo),
onTap: () async {
await launch(_sourceRepo);
},
),
ListTile(
title:
Text(AppLocalizations.of(context).settingsTranslatorTitle),
subtitle: Text(translator.isEmpty
? "Help translating to your language"
: translator),
onTap: () async {
await launch(_translationUrl);
},
),
],
),
),
],
);
}
Widget _buildCaption(BuildContext context, String label) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
label,
style: TextStyle(
color: Theme.of(context).accentColor,
),
),
);
}
void _onExifSupportChanged(BuildContext context, bool value) {
if (value) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(
AppLocalizations.of(context).exifSupportConfirmationDialogTitle),
content: Text(AppLocalizations.of(context).exifSupportDetails),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(AppLocalizations.of(context).enableButtonLabel),
),
],
),
).then((value) {
if (value == true) {
_setExifSupport(true);
}
});
} else {
_setExifSupport(false);
}
}
void _setExifSupport(bool value) {
final oldValue = _isEnableExif;
setState(() {
_isEnableExif = value;
});
Pref.inst().setEnableExif(value).then((result) {
if (result) {
if (value) {
KiwiContainer()
.resolve<MetadataTaskManager>()
.addTask(MetadataTask(widget.account));
}
} else {
_log.severe("[_setExifSupport] Failed writing pref");
SnackBarManager().showSnackBar(SnackBar(
content: Text(
AppLocalizations.of(context).writePreferenceFailureNotification),
duration: k.snackBarDurationNormal,
));
setState(() {
_isEnableExif = oldValue;
});
}
});
}
static const String _sourceRepo = "https://gitlab.com/nkming2/nc-photos";
static const String _translationUrl =
"https://gitlab.com/nkming2/nc-photos/lib/l10n";
bool _isEnableExif;
static final _log = Logger("widget.settings._SettingsState");
}

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