mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-02 22:52:12 +00:00
Compare commits
15 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6501590dd7 | |||
| d03c42659c | |||
| 849f748968 | |||
| 9e28bb82a2 | |||
| 9beb8ef9d7 | |||
| a688954d76 | |||
| 358f93979e | |||
| dc0ef25d73 | |||
| 7559434f86 | |||
| 62457f1f48 | |||
| 0602f043d2 | |||
| 0c32b41dd0 | |||
| c10dc19342 | |||
| 872592af21 | |||
| c7826ad6dd |
99 changed files with 2819 additions and 1254 deletions
68
.github/workflows/release_github.yml
vendored
68
.github/workflows/release_github.yml
vendored
|
|
@ -1,68 +0,0 @@
|
||||||
name: Publish on Github
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch: {}
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- pubspec.yaml
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build_and_publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Clone repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: stable
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Install requirements
|
|
||||||
run: sudo apt-get install protobuf-compiler
|
|
||||||
|
|
||||||
- name: Cloning sub-repos
|
|
||||||
run: git submodule update --init --recursive
|
|
||||||
|
|
||||||
# - name: Check flutter code
|
|
||||||
# run: |
|
|
||||||
# flutter pub get
|
|
||||||
# flutter analyze
|
|
||||||
# flutter test
|
|
||||||
|
|
||||||
- name: Check flutter code
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Create key.properties file
|
|
||||||
run: |
|
|
||||||
echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> ./android/key.properties
|
|
||||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> ./android/key.properties
|
|
||||||
echo "keyAlias=github-releases-signature" >> ./android/key.properties
|
|
||||||
echo "storeFile=./keystore.jks" >> ./android/key.properties
|
|
||||||
|
|
||||||
- name: Create keystore file
|
|
||||||
env:
|
|
||||||
KEYSTORE_FILE: ${{ secrets.KEYSTORE_FILE }}
|
|
||||||
run: echo $KEYSTORE_FILE | base64 --decode > ./android/app/keystore.jks
|
|
||||||
|
|
||||||
- name: Build Android APK
|
|
||||||
run: flutter build apk --release --split-per-abi
|
|
||||||
|
|
||||||
- name: Extract pubspec version
|
|
||||||
run: |
|
|
||||||
echo "PUBSPEC_VERSION=$(grep -oP 'version:\s*\K[^+]+(?=\+)' pubspec.yaml)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Upload Release Binaries (stable)
|
|
||||||
uses: ncipollo/release-action@v1.18.0
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
tag: v${{ env.PUBSPEC_VERSION }}
|
|
||||||
allowUpdates: true
|
|
||||||
artifacts: build/app/outputs/flutter-apk/*.apk
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -55,3 +55,4 @@ android/.kotlin/
|
||||||
devtools_options.yaml
|
devtools_options.yaml
|
||||||
rust/target
|
rust/target
|
||||||
rust_dependencies/target
|
rust_dependencies/target
|
||||||
|
fastlane/repo/status/running.json
|
||||||
|
|
|
||||||
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -1,5 +1,19 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.26
|
||||||
|
|
||||||
|
- New: Import images from the gallery
|
||||||
|
- Improved: Media files are now stored in the dedicated "twonly" album
|
||||||
|
- Improved: UI components adapt to native styling (iOS/Android)
|
||||||
|
- Fix: Migration issue that resulted in a corrupted backup mechanism
|
||||||
|
- Fix: Database issues causing messages to be lost or the database to be corrupted
|
||||||
|
- Fix: Permission view did not disappear after they were granted
|
||||||
|
|
||||||
|
## 0.2.23
|
||||||
|
|
||||||
|
- Improved: Smaller UI changes
|
||||||
|
- Fix: Some messages were not marked as opened.
|
||||||
|
|
||||||
## 0.2.20
|
## 0.2.20
|
||||||
|
|
||||||
- New: Adds an "Ask a Friend" button to new contact suggestions.
|
- New: Adds an "Ask a Friend" button to new contact suggestions.
|
||||||
|
|
|
||||||
2
android/.gitignore
vendored
2
android/.gitignore
vendored
|
|
@ -9,5 +9,7 @@ GeneratedPluginRegistrant.java
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore.
|
||||||
# See https://flutter.dev/to/reference-keystore
|
# See https://flutter.dev/to/reference-keystore
|
||||||
key.properties
|
key.properties
|
||||||
|
key.github.properties
|
||||||
|
key.properties.backup
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,32 @@ import android.content.Context
|
||||||
import io.crates.keyring.Keyring
|
import io.crates.keyring.Keyring
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.net.Uri
|
||||||
|
import java.io.InputStream
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterFragmentActivity() {
|
class MainActivity : FlutterFragmentActivity() {
|
||||||
|
private val CHANNEL = "eu.twonly/photo_picker"
|
||||||
|
private var pendingResult: MethodChannel.Result? = null
|
||||||
|
|
||||||
|
private lateinit var pickMultipleMedia: ActivityResultLauncher<PickVisualMediaRequest>
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
|
|
||||||
|
pickMultipleMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
|
||||||
|
if (uris.isNotEmpty()) {
|
||||||
|
val uriStrings = uris.map { it.toString() }
|
||||||
|
pendingResult?.success(uriStrings)
|
||||||
|
} else {
|
||||||
|
pendingResult?.success(emptyList<String>())
|
||||||
|
}
|
||||||
|
pendingResult = null
|
||||||
|
}
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,7 +56,36 @@ class MainActivity : FlutterFragmentActivity() {
|
||||||
|
|
||||||
Keyring.initializeNdkContext(applicationContext)
|
Keyring.initializeNdkContext(applicationContext)
|
||||||
|
|
||||||
MediaStoreChannel.configure(flutterEngine, applicationContext)
|
|
||||||
VideoCompressionChannel.configure(flutterEngine, applicationContext)
|
VideoCompressionChannel.configure(flutterEngine, applicationContext)
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"pickImages" -> {
|
||||||
|
pendingResult = result
|
||||||
|
pickMultipleMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||||
|
}
|
||||||
|
"getUriBytes" -> {
|
||||||
|
val uriString = call.argument<String>("uri")
|
||||||
|
if (uriString != null) {
|
||||||
|
try {
|
||||||
|
val uri = Uri.parse(uriString)
|
||||||
|
val inputStream: InputStream? = contentResolver.openInputStream(uri)
|
||||||
|
if (inputStream != null) {
|
||||||
|
val bytes = inputStream.readBytes()
|
||||||
|
inputStream.close()
|
||||||
|
result.success(bytes)
|
||||||
|
} else {
|
||||||
|
result.error("UNAVAILABLE", "Could not open InputStream", null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_ARGUMENT", "URI string is null", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
package eu.twonly
|
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
|
|
||||||
object MediaStoreChannel {
|
|
||||||
private const val CHANNEL = "eu.twonly/mediaStore"
|
|
||||||
|
|
||||||
fun configure(flutterEngine: FlutterEngine, context: Context) {
|
|
||||||
val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
|
||||||
|
|
||||||
channel.setMethodCallHandler { call, result ->
|
|
||||||
try {
|
|
||||||
if (call.method == "safeFileToDownload") {
|
|
||||||
val arguments = call.arguments<Map<String, String>>() as Map<String, String>
|
|
||||||
val sourceFile = arguments["sourceFile"]
|
|
||||||
if (sourceFile == null) {
|
|
||||||
result.success(false)
|
|
||||||
} else {
|
|
||||||
val inputStream = FileInputStream(File(sourceFile))
|
|
||||||
val outputName = File(sourceFile).name.takeIf { it.isNotEmpty() } ?: "memories.zip"
|
|
||||||
|
|
||||||
val savedUri = saveZipToDownloads(context, outputName, inputStream)
|
|
||||||
if (savedUri != null) {
|
|
||||||
result.success(savedUri.toString())
|
|
||||||
} else {
|
|
||||||
result.error("SAVE_FAILED", "Could not save ZIP", null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.notImplemented()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("EXCEPTION", e.message, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveZipToDownloads(
|
|
||||||
context: Context,
|
|
||||||
fileName: String = "archive.zip",
|
|
||||||
sourceStream: InputStream
|
|
||||||
): android.net.Uri? {
|
|
||||||
val resolver = context.contentResolver
|
|
||||||
|
|
||||||
val contentValues = ContentValues().apply {
|
|
||||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
|
||||||
put(MediaStore.MediaColumns.MIME_TYPE, "application/zip")
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
|
||||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
|
||||||
} else {
|
|
||||||
MediaStore.Files.getContentUri("external")
|
|
||||||
}
|
|
||||||
|
|
||||||
val uri = resolver.insert(collection, contentValues) ?: return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
resolver.openOutputStream(uri).use { out: OutputStream? ->
|
|
||||||
requireNotNull(out) { "Unable to open output stream" }
|
|
||||||
sourceStream.use { input ->
|
|
||||||
input.copyTo(out)
|
|
||||||
}
|
|
||||||
out.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
val done = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
|
|
||||||
resolver.update(uri, done, null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return uri
|
|
||||||
} catch (e: Exception) {
|
|
||||||
try { resolver.delete(uri, null, null) } catch (_: Exception) {}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
android/key.github.properties.example
Normal file
8
android/key.github.properties.example
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Example signing credentials configuration for GitHub Releases.
|
||||||
|
# Copy this file to 'key.github.properties' and fill in your actual credentials.
|
||||||
|
# Do not commit the actual 'key.github.properties' file to version control.
|
||||||
|
|
||||||
|
storePassword=YOUR_GITHUB_RELEASE_STORE_PASSWORD
|
||||||
|
keyPassword=YOUR_GITHUB_RELEASE_KEY_PASSWORD
|
||||||
|
keyAlias=github-releases-signature
|
||||||
|
storeFile=/absolute/path/to/your/github-release-keystore.jks
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit e0c6a9617a20a8d6bc1ad4c6b9c2e229feb5f37a
|
Subproject commit 72d9bd6320bca1f1d29c6e61c3821fed326c0abe
|
||||||
|
|
@ -12,4 +12,133 @@ platform :android do
|
||||||
skip_upload_screenshots: true
|
skip_upload_screenshots: true
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
desc "Build the application locally and upload it as a new GitHub release"
|
||||||
|
lane :release_github do
|
||||||
|
# Read pubspec.yaml to get the version
|
||||||
|
pubspec_path = File.expand_path("../pubspec.yaml", __dir__)
|
||||||
|
unless File.exist?(pubspec_path)
|
||||||
|
UI.user_error!("Could not find pubspec.yaml at #{pubspec_path}")
|
||||||
|
end
|
||||||
|
|
||||||
|
pubspec_content = File.read(pubspec_path)
|
||||||
|
version_match = pubspec_content.match(/^version:\s*([^+]+)/)
|
||||||
|
unless version_match
|
||||||
|
UI.user_error!("Could not extract version from pubspec.yaml")
|
||||||
|
end
|
||||||
|
|
||||||
|
version = version_match[1].strip
|
||||||
|
tag_name = "v#{version}"
|
||||||
|
UI.message("Extracted version: #{version} (tag: #{tag_name})")
|
||||||
|
|
||||||
|
# Load release notes from CHANGELOG.md
|
||||||
|
changelog_path = File.expand_path("../CHANGELOG.md", __dir__)
|
||||||
|
release_notes = "Automated local release via Fastlane"
|
||||||
|
if File.exist?(changelog_path)
|
||||||
|
changelog_content = File.read(changelog_path)
|
||||||
|
escaped_version = Regexp.escape(version)
|
||||||
|
pattern = /##\s*\[?#{escaped_version}\]?(.*?)(?=##\s*|\z)/m
|
||||||
|
match = changelog_content.match(pattern)
|
||||||
|
if match
|
||||||
|
release_notes = match[1].strip
|
||||||
|
UI.message("Loaded release notes from CHANGELOG.md:\n#{release_notes}")
|
||||||
|
else
|
||||||
|
UI.important("Could not find release notes for version #{version} in CHANGELOG.md. Using default description.")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
UI.important("CHANGELOG.md not found at #{changelog_path}. Using default description.")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle key.properties swapping if key.github.properties exists
|
||||||
|
key_properties_path = File.expand_path("../android/key.properties", __dir__)
|
||||||
|
github_properties_path = File.expand_path("../android/key.github.properties", __dir__)
|
||||||
|
backup_properties_path = File.expand_path("../android/key.properties.backup", __dir__)
|
||||||
|
|
||||||
|
swapped_properties = false
|
||||||
|
if File.exist?(github_properties_path)
|
||||||
|
UI.message("Found key.github.properties. Swapping in for the build...")
|
||||||
|
if File.exist?(key_properties_path)
|
||||||
|
FileUtils.cp(key_properties_path, backup_properties_path)
|
||||||
|
end
|
||||||
|
FileUtils.cp(github_properties_path, key_properties_path)
|
||||||
|
swapped_properties = true
|
||||||
|
else
|
||||||
|
UI.message("No key.github.properties found. Building with default key.properties...")
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Build the Android application
|
||||||
|
UI.message("Building Android APK...")
|
||||||
|
Dir.chdir(File.expand_path("..", __dir__)) do
|
||||||
|
sh("flutter build apk --release --split-per-abi")
|
||||||
|
end
|
||||||
|
ensure
|
||||||
|
# Restore original key.properties if swapped
|
||||||
|
if swapped_properties
|
||||||
|
UI.message("Restoring original key.properties...")
|
||||||
|
if File.exist?(backup_properties_path)
|
||||||
|
FileUtils.cp(backup_properties_path, key_properties_path)
|
||||||
|
FileUtils.rm(backup_properties_path)
|
||||||
|
else
|
||||||
|
FileUtils.rm_f(key_properties_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find built APKs
|
||||||
|
apk_glob = File.expand_path("../build/app/outputs/flutter-apk/*-release.apk", __dir__)
|
||||||
|
apks = Dir.glob(apk_glob)
|
||||||
|
|
||||||
|
if apks.empty?
|
||||||
|
UI.user_error!("No release APKs found matching #{apk_glob}")
|
||||||
|
end
|
||||||
|
|
||||||
|
UI.message("Found APKs to upload: #{apks.join(', ')}")
|
||||||
|
|
||||||
|
# Retrieve GitHub Token (fall back to gh auth token)
|
||||||
|
github_token = ENV["GITHUB_TOKEN"]
|
||||||
|
if github_token.nil? || github_token.empty?
|
||||||
|
UI.message("GITHUB_TOKEN env variable not set. Retrieving token via GitHub CLI (gh auth token)...")
|
||||||
|
begin
|
||||||
|
github_token = sh("gh auth token").strip
|
||||||
|
rescue => e
|
||||||
|
UI.user_error!("Failed to retrieve token from gh CLI. Make sure gh is installed and authenticated, or GITHUB_TOKEN environment variable is set. Error: #{e}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
UI.message("Creating GitHub Release #{tag_name}...")
|
||||||
|
set_github_release(
|
||||||
|
repository_name: "twonlyapp/twonly-app",
|
||||||
|
api_token: github_token,
|
||||||
|
tag_name: tag_name,
|
||||||
|
name: "Release #{tag_name}",
|
||||||
|
description: release_notes,
|
||||||
|
upload_assets: apks
|
||||||
|
)
|
||||||
|
UI.success("Successfully uploaded release #{tag_name} to GitHub!")
|
||||||
|
|
||||||
|
# F-Droid deployment
|
||||||
|
fdroid_repo_dir = "/Users/tobi/Documents/drive/twonly/F-Droid/repo"
|
||||||
|
UI.message("Starting F-Droid deployment...")
|
||||||
|
FileUtils.mkdir_p(fdroid_repo_dir)
|
||||||
|
|
||||||
|
apks.each do |apk_path|
|
||||||
|
basename = File.basename(apk_path)
|
||||||
|
new_name = "eu.twonly_v#{version}-#{basename}"
|
||||||
|
dest_path = File.join(fdroid_repo_dir, new_name)
|
||||||
|
UI.message("Copying APK to F-Droid repo: #{dest_path}")
|
||||||
|
FileUtils.cp(apk_path, dest_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
fdroid_dir = "/Users/tobi/Documents/drive/twonly/F-Droid"
|
||||||
|
update_script = File.join(fdroid_dir, "update.sh")
|
||||||
|
if File.exist?(update_script)
|
||||||
|
UI.message("Executing F-Droid update script...")
|
||||||
|
Dir.chdir(fdroid_dir) do
|
||||||
|
sh("chmod +x ./update.sh && ./update.sh")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
UI.important("F-Droid update script not found at #{update_script}")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,9 @@ PODS:
|
||||||
- Flutter
|
- Flutter
|
||||||
- permission_handler_apple (9.3.0):
|
- permission_handler_apple (9.3.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- photo_manager (3.9.0):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- pro_video_editor (0.0.1):
|
- pro_video_editor (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- PromisesObjC (2.4.0)
|
- PromisesObjC (2.4.0)
|
||||||
|
|
@ -355,6 +358,7 @@ DEPENDENCIES:
|
||||||
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
|
- photo_manager (from `.symlinks/plugins/photo_manager/darwin`)
|
||||||
- pro_video_editor (from `.symlinks/plugins/pro_video_editor/ios`)
|
- pro_video_editor (from `.symlinks/plugins/pro_video_editor/ios`)
|
||||||
- restart_app (from `.symlinks/plugins/restart_app/ios`)
|
- restart_app (from `.symlinks/plugins/restart_app/ios`)
|
||||||
- rust_lib_twonly (from `.symlinks/plugins/rust_lib_twonly/ios`)
|
- rust_lib_twonly (from `.symlinks/plugins/rust_lib_twonly/ios`)
|
||||||
|
|
@ -460,6 +464,8 @@ EXTERNAL SOURCES:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
|
photo_manager:
|
||||||
|
:path: ".symlinks/plugins/photo_manager/darwin"
|
||||||
pro_video_editor:
|
pro_video_editor:
|
||||||
:path: ".symlinks/plugins/pro_video_editor/ios"
|
:path: ".symlinks/plugins/pro_video_editor/ios"
|
||||||
restart_app:
|
restart_app:
|
||||||
|
|
@ -536,6 +542,7 @@ SPEC CHECKSUMS:
|
||||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
|
photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb
|
||||||
pro_video_editor: 44ef9a6d48dbd757ed428cf35396dd05f35c7830
|
pro_video_editor: 44ef9a6d48dbd757ed428cf35396dd05f35c7830
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
restart_app: 0714144901e260eae68f7afc2fc4aacc1a323ad2
|
restart_app: 0714144901e260eae68f7afc2fc4aacc1a323ad2
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ class Routes {
|
||||||
static const String settingsStorage = '/settings/storage_data';
|
static const String settingsStorage = '/settings/storage_data';
|
||||||
static const String settingsStorageManage = '/settings/storage_data/manage';
|
static const String settingsStorageManage = '/settings/storage_data/manage';
|
||||||
static const String settingsStorageImport = '/settings/storage_data/import';
|
static const String settingsStorageImport = '/settings/storage_data/import';
|
||||||
|
static const String settingsStorageImportGallery =
|
||||||
|
'/settings/storage_data/import_gallery';
|
||||||
static const String settingsStorageExport = '/settings/storage_data/export';
|
static const String settingsStorageExport = '/settings/storage_data/export';
|
||||||
static const String settingsHelp = '/settings/help';
|
static const String settingsHelp = '/settings/help';
|
||||||
static const String settingsHelpFaq = '/settings/help/faq';
|
static const String settingsHelpFaq = '/settings/help/faq';
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,12 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
||||||
return rows.map((row) => row.readTable(db.messages).messageId).toList();
|
return rows.map((row) => row.readTable(db.messages).messageId).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<MediaFile>> getMediaByHash(Uint8List hash) async {
|
||||||
|
final query = select(db.mediaFiles)
|
||||||
|
..where((t) => t.storedFileHash.equals(hash));
|
||||||
|
return query.get();
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<MediaType, int>> getStorageStats() async {
|
Future<Map<MediaType, int>> getStorageStats() async {
|
||||||
final rows = await select(mediaFiles).get();
|
final rows = await select(mediaFiles).get();
|
||||||
final stats = <MediaType, int>{};
|
final stats = <MediaType, int>{};
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
t.openedAt.isNull() |
|
t.openedAt.isNull() |
|
||||||
t.mediaStored.equals(true)) &
|
t.mediaStored.equals(true)) &
|
||||||
(t.isDeletedFromSender.equals(true) |
|
(t.isDeletedFromSender.equals(true) |
|
||||||
(t.type.equals(MessageType.text.name).not() |
|
(t.type.equals(MessageType.text.name).not() &
|
||||||
t.type.equals(MessageType.media.name).not()) |
|
t.type.equals(MessageType.media.name).not()) |
|
||||||
(t.type.equals(MessageType.text.name) &
|
(t.type.equals(MessageType.text.name) &
|
||||||
t.content.isNotNull()) |
|
t.content.isNotNull()) |
|
||||||
|
|
@ -153,18 +153,25 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
);
|
);
|
||||||
final groupIds = entry.value;
|
final groupIds = entry.value;
|
||||||
|
|
||||||
await (delete(messages)..where(
|
final deletedCount =
|
||||||
(m) =>
|
await (delete(messages)..where(
|
||||||
m.groupId.isIn(groupIds) &
|
(m) =>
|
||||||
(m.mediaStored.equals(true) &
|
m.groupId.isIn(groupIds) &
|
||||||
m.isDeletedFromSender.equals(true) |
|
((m.mediaStored.equals(true) &
|
||||||
m.mediaStored.equals(false)) &
|
m.isDeletedFromSender.equals(true)) |
|
||||||
// Only remove the message when ALL members have seen it. Otherwise the receipt will also be deleted which could cause issues in case a member opens the image later..
|
m.mediaStored.equals(false)) &
|
||||||
(m.openedByAll.isSmallerThanValue(deletionTime) |
|
// Only remove the message when ALL members have seen it. Otherwise the receipt will also be deleted which could cause issues in case a member opens the image later..
|
||||||
(m.isDeletedFromSender.equals(true) &
|
(m.openedByAll.isSmallerThanValue(deletionTime) |
|
||||||
m.createdAt.isSmallerThanValue(deletionTime))),
|
(m.isDeletedFromSender.equals(true) &
|
||||||
))
|
m.createdAt.isSmallerThanValue(deletionTime))),
|
||||||
.go();
|
))
|
||||||
|
.go();
|
||||||
|
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
Log.info(
|
||||||
|
'Deleted $deletedCount messages for groups $groupIds due to retention policy.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,41 +260,64 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
List<String> messageIds,
|
List<String> messageIds,
|
||||||
DateTime timestamp,
|
DateTime timestamp,
|
||||||
) async {
|
) async {
|
||||||
try {
|
for (final messageId in messageIds) {
|
||||||
await twonlyDB.batch((batch) async {
|
try {
|
||||||
for (final messageId in messageIds) {
|
var actionTimestamp = timestamp;
|
||||||
batch.insert(
|
final msg = await getMessageById(messageId).getSingleOrNull();
|
||||||
messageActions,
|
if (msg != null && actionTimestamp.isBefore(msg.createdAt)) {
|
||||||
|
Log.warn(
|
||||||
|
'Receiver clock skew detected for message $messageId. '
|
||||||
|
'Action timestamp $actionTimestamp is before message creation ${msg.createdAt}. '
|
||||||
|
'Clamping to creation time.',
|
||||||
|
);
|
||||||
|
actionTimestamp = msg.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ts = actionTimestamp;
|
||||||
|
await transaction(() async {
|
||||||
|
await into(messageActions).insertOnConflictUpdate(
|
||||||
MessageActionsCompanion(
|
MessageActionsCompanion(
|
||||||
messageId: Value(messageId),
|
messageId: Value(messageId),
|
||||||
contactId: contactId,
|
contactId: contactId,
|
||||||
type: const Value(MessageActionType.openedAt),
|
type: const Value(MessageActionType.openedAt),
|
||||||
actionAt: Value(timestamp),
|
actionAt: Value(ts),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final isOpenedByAll = await haveAllMembers(
|
||||||
|
messageId,
|
||||||
|
MessageActionType.openedAt,
|
||||||
|
);
|
||||||
|
await (update(
|
||||||
|
messages,
|
||||||
|
)..where((tbl) => tbl.messageId.equals(messageId))).write(
|
||||||
|
MessagesCompanion(
|
||||||
|
openedAt: Value(ts),
|
||||||
|
openedByAll: Value(isOpenedByAll ? ts : null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read-back verification: confirm the write was persisted.
|
||||||
|
final verified = await getMessageById(messageId).getSingleOrNull();
|
||||||
|
if (verified != null && verified.openedAt == null) {
|
||||||
|
Log.warn(
|
||||||
|
'handleMessagesOpened read-back failed for $messageId, retrying',
|
||||||
|
);
|
||||||
|
await (update(
|
||||||
|
messages,
|
||||||
|
)..where((tbl) => tbl.messageId.equals(messageId))).write(
|
||||||
|
MessagesCompanion(
|
||||||
|
openedAt: Value(actionTimestamp),
|
||||||
),
|
),
|
||||||
mode: InsertMode.insertOrReplace,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
Log.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final messageId in messageIds) {
|
Log.info(
|
||||||
try {
|
'handleMessagesOpened completed for message $messageId',
|
||||||
final isOpenedByAll = await haveAllMembers(
|
|
||||||
messageId,
|
|
||||||
MessageActionType.openedAt,
|
|
||||||
);
|
|
||||||
await (update(
|
|
||||||
messages,
|
|
||||||
)..where((tbl) => tbl.messageId.equals(messageId))).write(
|
|
||||||
MessagesCompanion(
|
|
||||||
openedAt: Value(timestamp),
|
|
||||||
openedByAll: Value(isOpenedByAll ? timestamp : null),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.error(e);
|
Log.error('handleMessagesOpened failed for $messageId: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -297,18 +327,20 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
String messageId,
|
String messageId,
|
||||||
DateTime timestamp,
|
DateTime timestamp,
|
||||||
) async {
|
) async {
|
||||||
await into(messageActions).insertOnConflictUpdate(
|
await transaction(() async {
|
||||||
MessageActionsCompanion(
|
await into(messageActions).insertOnConflictUpdate(
|
||||||
messageId: Value(messageId),
|
MessageActionsCompanion(
|
||||||
contactId: Value(contactId),
|
messageId: Value(messageId),
|
||||||
type: const Value(MessageActionType.ackByServerAt),
|
contactId: Value(contactId),
|
||||||
actionAt: Value(timestamp),
|
type: const Value(MessageActionType.ackByServerAt),
|
||||||
),
|
actionAt: Value(timestamp),
|
||||||
);
|
),
|
||||||
await twonlyDB.messagesDao.updateMessageId(
|
);
|
||||||
messageId,
|
await twonlyDB.messagesDao.updateMessageId(
|
||||||
MessagesCompanion(ackByServer: Value(timestamp)),
|
messageId,
|
||||||
);
|
MessagesCompanion(ackByServer: Value(timestamp)),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> haveAllMembers(
|
Future<bool> haveAllMembers(
|
||||||
|
|
@ -342,18 +374,20 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
String messageId,
|
String messageId,
|
||||||
MessagesCompanion updatedValues,
|
MessagesCompanion updatedValues,
|
||||||
) async {
|
) async {
|
||||||
await (update(
|
final count = await (update(
|
||||||
messages,
|
messages,
|
||||||
)..where((c) => c.messageId.equals(messageId))).write(updatedValues);
|
)..where((c) => c.messageId.equals(messageId))).write(updatedValues);
|
||||||
|
Log.info('Updated $count message(s) with messageId $messageId');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateMessagesByMediaId(
|
Future<void> updateMessagesByMediaId(
|
||||||
String mediaId,
|
String mediaId,
|
||||||
MessagesCompanion updatedValues,
|
MessagesCompanion updatedValues,
|
||||||
) {
|
) async {
|
||||||
return (update(
|
final count = await (update(
|
||||||
messages,
|
messages,
|
||||||
)..where((c) => c.mediaId.equals(mediaId))).write(updatedValues);
|
)..where((c) => c.mediaId.equals(mediaId))).write(updatedValues);
|
||||||
|
Log.info('Updated $count message(s) with mediaId $mediaId');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Message?> insertMessage(MessagesCompanion message) async {
|
Future<Message?> insertMessage(MessagesCompanion message) async {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,14 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
|
||||||
final msg = await twonlyDB.messagesDao
|
final msg = await twonlyDB.messagesDao
|
||||||
.getMessageById(messageId)
|
.getMessageById(messageId)
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
if (msg == null || msg.groupId != groupId) return;
|
if (msg == null) {
|
||||||
|
Log.error('updateReaction: Message $messageId not found!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.groupId != groupId) {
|
||||||
|
Log.error('updateReaction: Message groupId ${msg.groupId} != $groupId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (remove) {
|
if (remove) {
|
||||||
|
|
|
||||||
164
lib/src/database/drift_logging_interceptor.dart
Normal file
164
lib/src/database/drift_logging_interceptor.dart
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:twonly/locator.dart';
|
||||||
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
|
||||||
|
class DriftLoggingInterceptor extends QueryInterceptor {
|
||||||
|
bool get _isEnabled {
|
||||||
|
try {
|
||||||
|
if (!userService.isUserCreated) return false;
|
||||||
|
return userService.currentUser.enableDatabaseLogging;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _findUuids(dynamic value) {
|
||||||
|
if (value == null) return const [];
|
||||||
|
final uuids = <String>[];
|
||||||
|
final uuidRegex = RegExp(
|
||||||
|
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}',
|
||||||
|
);
|
||||||
|
if (value is String) {
|
||||||
|
for (final match in uuidRegex.allMatches(value)) {
|
||||||
|
uuids.add(match.group(0)!);
|
||||||
|
}
|
||||||
|
} else if (value is Iterable) {
|
||||||
|
for (final element in value) {
|
||||||
|
uuids.addAll(_findUuids(element));
|
||||||
|
}
|
||||||
|
} else if (value is Map) {
|
||||||
|
for (final element in value.values) {
|
||||||
|
uuids.addAll(_findUuids(element));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final str = value.toString();
|
||||||
|
for (final match in uuidRegex.allMatches(str)) {
|
||||||
|
uuids.add(match.group(0)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uuids.toSet().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T> _run<T>(
|
||||||
|
String operation,
|
||||||
|
String statement,
|
||||||
|
List<Object?> args,
|
||||||
|
Future<T> Function() query,
|
||||||
|
) async {
|
||||||
|
if (!_isEnabled) {
|
||||||
|
return query();
|
||||||
|
}
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
try {
|
||||||
|
final result = await query();
|
||||||
|
final elapsed = stopwatch.elapsedMilliseconds;
|
||||||
|
final uuids = _findUuids(args);
|
||||||
|
if (uuids.isNotEmpty) {
|
||||||
|
Log.info(
|
||||||
|
'[DriftDB] $operation succeeded in ${elapsed}ms: "$statement" | UUIDs: $uuids',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Log.info(
|
||||||
|
'[DriftDB] $operation succeeded in ${elapsed}ms: "$statement"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
final elapsed = stopwatch.elapsedMilliseconds;
|
||||||
|
final uuids = _findUuids(args);
|
||||||
|
if (uuids.isNotEmpty) {
|
||||||
|
Log.info(
|
||||||
|
'[DriftDB] $operation failed after ${elapsed}ms ($e): "$statement" | UUIDs: $uuids',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Log.info(
|
||||||
|
'[DriftDB] $operation failed after ${elapsed}ms ($e): "$statement"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> runInsert(
|
||||||
|
QueryExecutor executor,
|
||||||
|
String statement,
|
||||||
|
List<Object?> args,
|
||||||
|
) {
|
||||||
|
return _run('INSERT', statement, args, () => executor.runInsert(statement, args));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> runUpdate(
|
||||||
|
QueryExecutor executor,
|
||||||
|
String statement,
|
||||||
|
List<Object?> args,
|
||||||
|
) {
|
||||||
|
return _run('UPDATE', statement, args, () => executor.runUpdate(statement, args));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> runDelete(
|
||||||
|
QueryExecutor executor,
|
||||||
|
String statement,
|
||||||
|
List<Object?> args,
|
||||||
|
) {
|
||||||
|
return _run('DELETE', statement, args, () => executor.runDelete(statement, args));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> runCustom(
|
||||||
|
QueryExecutor executor,
|
||||||
|
String statement,
|
||||||
|
List<Object?> args,
|
||||||
|
) {
|
||||||
|
return _run('CUSTOM', statement, args, () => executor.runCustom(statement, args));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> runBatched(
|
||||||
|
QueryExecutor executor,
|
||||||
|
BatchedStatements statements,
|
||||||
|
) async {
|
||||||
|
if (!_isEnabled) {
|
||||||
|
return executor.runBatched(statements);
|
||||||
|
}
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
try {
|
||||||
|
await executor.runBatched(statements);
|
||||||
|
final elapsed = stopwatch.elapsedMilliseconds;
|
||||||
|
final uuids = <String>[];
|
||||||
|
for (final batchArg in statements.arguments) {
|
||||||
|
uuids.addAll(_findUuids(batchArg.arguments));
|
||||||
|
}
|
||||||
|
final statementsStr = statements.statements.join('; ');
|
||||||
|
if (uuids.isNotEmpty) {
|
||||||
|
Log.info(
|
||||||
|
'[DriftDB] BATCH succeeded in ${elapsed}ms: "$statementsStr" | UUIDs: $uuids',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Log.info(
|
||||||
|
'[DriftDB] BATCH succeeded in ${elapsed}ms: "$statementsStr"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
final elapsed = stopwatch.elapsedMilliseconds;
|
||||||
|
final uuids = <String>[];
|
||||||
|
for (final batchArg in statements.arguments) {
|
||||||
|
uuids.addAll(_findUuids(batchArg.arguments));
|
||||||
|
}
|
||||||
|
final statementsStr = statements.statements.join('; ');
|
||||||
|
if (uuids.isNotEmpty) {
|
||||||
|
Log.info(
|
||||||
|
'[DriftDB] BATCH failed after ${elapsed}ms ($e): "$statementsStr" | UUIDs: $uuids',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Log.info(
|
||||||
|
'[DriftDB] BATCH failed after ${elapsed}ms ($e): "$statementsStr"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import 'package:clock/clock.dart';
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:drift_flutter/drift_flutter.dart'
|
import 'package:drift_flutter/drift_flutter.dart'
|
||||||
show DriftNativeOptions, driftDatabase;
|
show DriftNativeOptions, driftDatabase;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||||
import 'package:twonly/src/database/daos/groups.dao.dart';
|
import 'package:twonly/src/database/daos/groups.dao.dart';
|
||||||
import 'package:twonly/src/database/daos/key_verification.dao.dart';
|
import 'package:twonly/src/database/daos/key_verification.dao.dart';
|
||||||
|
|
@ -12,6 +12,7 @@ import 'package:twonly/src/database/daos/reactions.dao.dart';
|
||||||
import 'package:twonly/src/database/daos/receipts.dao.dart';
|
import 'package:twonly/src/database/daos/receipts.dao.dart';
|
||||||
import 'package:twonly/src/database/daos/shortcuts.dao.dart';
|
import 'package:twonly/src/database/daos/shortcuts.dao.dart';
|
||||||
import 'package:twonly/src/database/daos/user_discovery.dao.dart';
|
import 'package:twonly/src/database/daos/user_discovery.dao.dart';
|
||||||
|
import 'package:twonly/src/database/drift_logging_interceptor.dart';
|
||||||
import 'package:twonly/src/database/tables/contacts.table.dart';
|
import 'package:twonly/src/database/tables/contacts.table.dart';
|
||||||
import 'package:twonly/src/database/tables/groups.table.dart';
|
import 'package:twonly/src/database/tables/groups.table.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
|
|
@ -84,18 +85,26 @@ class TwonlyDB extends _$TwonlyDB {
|
||||||
int get schemaVersion => 17;
|
int get schemaVersion => 17;
|
||||||
|
|
||||||
static QueryExecutor _openConnection() {
|
static QueryExecutor _openConnection() {
|
||||||
return driftDatabase(
|
final connection = driftDatabase(
|
||||||
name: 'twonly',
|
name: 'twonly',
|
||||||
native: DriftNativeOptions(
|
native: DriftNativeOptions(
|
||||||
databaseDirectory: getApplicationSupportDirectory,
|
databaseDirectory: getApplicationSupportDirectory,
|
||||||
shareAcrossIsolates: true,
|
shareAcrossIsolates: true,
|
||||||
setup: (rawDb) {
|
setup: (rawDb) {
|
||||||
rawDb
|
rawDb
|
||||||
..execute('PRAGMA journal_mode=WAL;')
|
..execute('PRAGMA journal_mode=DELETE;')
|
||||||
|
..execute('PRAGMA synchronous=FULL;')
|
||||||
..execute('PRAGMA busy_timeout=5000;');
|
..execute('PRAGMA busy_timeout=5000;');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
|
if (userService.isUserCreated &&
|
||||||
|
userService.currentUser.enableDatabaseLogging) {
|
||||||
|
return connection.interceptWith(DriftLoggingInterceptor());
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -245,38 +254,4 @@ class TwonlyDB extends _$TwonlyDB {
|
||||||
Log.info('Table: $tableName, Size: $tableSize bytes');
|
Log.info('Table: $tableName, Size: $tableSize bytes');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteDataForTwonlySafe() async {
|
|
||||||
await (delete(messages)..where(
|
|
||||||
(t) =>
|
|
||||||
(t.mediaStored.equals(false) &
|
|
||||||
t.isDeletedFromSender.equals(false)),
|
|
||||||
))
|
|
||||||
.go();
|
|
||||||
await update(messages).write(
|
|
||||||
const MessagesCompanion(
|
|
||||||
downloadToken: Value(null),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await (delete(mediaFiles)..where(
|
|
||||||
(t) => (t.stored.equals(false)),
|
|
||||||
))
|
|
||||||
.go();
|
|
||||||
await delete(receipts).go();
|
|
||||||
await delete(receivedReceipts).go();
|
|
||||||
await update(contacts).write(
|
|
||||||
const ContactsCompanion(
|
|
||||||
avatarSvgCompressed: Value(null),
|
|
||||||
senderProfileCounter: Value(0),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await (delete(signalPreKeyStores)..where(
|
|
||||||
(t) => (t.createdAt.isSmallerThanValue(
|
|
||||||
clock.now().subtract(
|
|
||||||
const Duration(days: 25),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
))
|
|
||||||
.go();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1460,6 +1460,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Delete for all'**
|
/// **'Delete for all'**
|
||||||
String get deleteOkBtnForAll;
|
String get deleteOkBtnForAll;
|
||||||
|
|
||||||
|
/// No description provided for @memoriesDeleteSnackbarSuccess.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1 {Deleted 1 item successfully} other {Deleted {count} items successfully}}'**
|
||||||
|
String memoriesDeleteSnackbarSuccess(num count);
|
||||||
|
|
||||||
/// No description provided for @deleteOkBtnForMe.
|
/// No description provided for @deleteOkBtnForMe.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -1478,6 +1484,12 @@ abstract class AppLocalizations {
|
||||||
/// **'The image will be irrevocably deleted.'**
|
/// **'The image will be irrevocably deleted.'**
|
||||||
String get deleteImageBody;
|
String get deleteImageBody;
|
||||||
|
|
||||||
|
/// No description provided for @deleteMemoriesBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1 {The image will be irrevocably deleted.} other {The {count} images will be irrevocably deleted.}}'**
|
||||||
|
String deleteMemoriesBody(num count);
|
||||||
|
|
||||||
/// No description provided for @settingsBackup.
|
/// No description provided for @settingsBackup.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -3493,6 +3505,190 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Verify now'**
|
/// **'Verify now'**
|
||||||
String get unverifiedWarningButton;
|
String get unverifiedWarningButton;
|
||||||
|
|
||||||
|
/// No description provided for @today.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Today'**
|
||||||
|
String get today;
|
||||||
|
|
||||||
|
/// No description provided for @yesterday.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Yesterday'**
|
||||||
|
String get yesterday;
|
||||||
|
|
||||||
|
/// No description provided for @galleryDisableWarningTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Disable gallery saving?'**
|
||||||
|
String get galleryDisableWarningTitle;
|
||||||
|
|
||||||
|
/// No description provided for @galleryDisableWarningBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'If you disable this, your media files will not be saved to your gallery and could be permanently lost if twonly is removed or has an issue, as media files are not yet backed up.'**
|
||||||
|
String get galleryDisableWarningBody;
|
||||||
|
|
||||||
|
/// No description provided for @galleryDisableWarningConfirm.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Disable'**
|
||||||
|
String get galleryDisableWarningConfirm;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageScanGalleryTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Import from Gallery'**
|
||||||
|
String get settingsStorageScanGalleryTitle;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryDeselectAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Deselect all'**
|
||||||
|
String get importGalleryDeselectAll;
|
||||||
|
|
||||||
|
/// No description provided for @importGallerySelectAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select all'**
|
||||||
|
String get importGallerySelectAll;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryPermissionRequired.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Permission to access your gallery is required to import previous twonly media files.'**
|
||||||
|
String get importGalleryPermissionRequired;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryPermissionError.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'An error occurred while requesting permission: {error}'**
|
||||||
|
String importGalleryPermissionError(Object error);
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryLoadError.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to load assets: {error}'**
|
||||||
|
String importGalleryLoadError(Object error);
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryImportingOf.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Importing {current} of {total}...'**
|
||||||
|
String importGalleryImportingOf(Object current, Object total);
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryStarting.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Starting import...'**
|
||||||
|
String get importGalleryStarting;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryComplete.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Import complete: {imported} successfully imported, {duplicated} duplicated and {failed} failed.'**
|
||||||
|
String importGalleryComplete(
|
||||||
|
Object imported,
|
||||||
|
Object duplicated,
|
||||||
|
Object failed,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryGrantAccess.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Grant Access'**
|
||||||
|
String get importGalleryGrantAccess;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryOpenSettings.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open Settings'**
|
||||||
|
String get importGalleryOpenSettings;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryPermissionDenied.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Permission to access gallery denied.'**
|
||||||
|
String get importGalleryPermissionDenied;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryTryAgain.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Try Again'**
|
||||||
|
String get importGalleryTryAgain;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryAlbumNotFound.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'\"twonly\" album not found'**
|
||||||
|
String get importGalleryAlbumNotFound;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryAlbumNotFoundDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'If you don\'t have this album yet, you can also create it to import photos into twonly.'**
|
||||||
|
String get importGalleryAlbumNotFoundDesc;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryNoImagesFound.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No images found'**
|
||||||
|
String get importGalleryNoImagesFound;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryNoImagesFoundDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'There are no images on your device.'**
|
||||||
|
String get importGalleryNoImagesFoundDesc;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryShowAllImages.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show all images'**
|
||||||
|
String get importGalleryShowAllImages;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryShowTwonlyAlbum.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show twonly album'**
|
||||||
|
String get importGalleryShowTwonlyAlbum;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryToggleDescAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Viewing all images on your device.'**
|
||||||
|
String get importGalleryToggleDescAll;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryToggleDescTwonly.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Viewing the \"twonly\" album.'**
|
||||||
|
String get importGalleryToggleDescTwonly;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryFilterTwonly.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Only show the twonly-Album'**
|
||||||
|
String get importGalleryFilterTwonly;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryRefresh.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Refresh'**
|
||||||
|
String get importGalleryRefresh;
|
||||||
|
|
||||||
|
/// No description provided for @importGallerySelectToImport.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select items to import'**
|
||||||
|
String get importGallerySelectToImport;
|
||||||
|
|
||||||
|
/// No description provided for @importGalleryImportCount.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{Import 1 item} other{Import {count} items}}'**
|
||||||
|
String importGalleryImportCount(num count);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -752,6 +752,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get deleteOkBtnForAll => 'Für alle löschen';
|
String get deleteOkBtnForAll => 'Für alle löschen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String memoriesDeleteSnackbarSuccess(num count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count Elemente erfolgreich gelöscht',
|
||||||
|
one: '1 Element erfolgreich gelöscht',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get deleteOkBtnForMe => 'Für mich löschen';
|
String get deleteOkBtnForMe => 'Für mich löschen';
|
||||||
|
|
||||||
|
|
@ -761,6 +772,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get deleteImageBody => 'Das Bild wird unwiderruflich gelöscht.';
|
String get deleteImageBody => 'Das Bild wird unwiderruflich gelöscht.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deleteMemoriesBody(num count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'Die $count Bilder werden unwiderruflich gelöscht.',
|
||||||
|
one: 'Das Bild wird unwiderruflich gelöscht.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsBackup => 'Backup';
|
String get settingsBackup => 'Backup';
|
||||||
|
|
||||||
|
|
@ -1988,4 +2010,121 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get unverifiedWarningButton => 'Jetzt verifizieren';
|
String get unverifiedWarningButton => 'Jetzt verifizieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get today => 'Heute';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yesterday => 'Gestern';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryDisableWarningTitle => 'Galeriespeicherung deaktivieren?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryDisableWarningBody =>
|
||||||
|
'Wenn du dies deaktivierst, werden deine Mediendateien nicht in deiner Galerie gespeichert und könnten dauerhaft verloren gehen, wenn twonly deinstalliert wird oder ein Problem auftritt, da Mediendateien noch nicht in Backups enthalten sind.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryDisableWarningConfirm => 'Deaktivieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageScanGalleryTitle => 'Aus Galerie importieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryDeselectAll => 'Alle abwählen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGallerySelectAll => 'Alle auswählen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryPermissionRequired =>
|
||||||
|
'Zugriff auf deine Galerie ist erforderlich, um frühere twonly-Mediendateien zu importieren.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String importGalleryPermissionError(Object error) {
|
||||||
|
return 'Beim Anfordern der Berechtigung ist ein Fehler aufgetreten: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String importGalleryLoadError(Object error) {
|
||||||
|
return 'Laden der Medien fehlgeschlagen: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String importGalleryImportingOf(Object current, Object total) {
|
||||||
|
return '$current von $total wird importiert...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryStarting => 'Import wird gestartet...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String importGalleryComplete(
|
||||||
|
Object imported,
|
||||||
|
Object duplicated,
|
||||||
|
Object failed,
|
||||||
|
) {
|
||||||
|
return 'Import abgeschlossen: $imported erfolgreich importiert, $duplicated Duplikate und $failed fehlgeschlagen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryGrantAccess => 'Zugriff erlauben';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryOpenSettings => 'Einstellungen öffnen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryPermissionDenied => 'Zugriff auf Galerie verweigert.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryTryAgain => 'Erneut versuchen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryAlbumNotFound => '\"twonly\"-Album nicht gefunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryAlbumNotFoundDesc =>
|
||||||
|
'Falls du dieses Album noch nicht hast, kannst du es auch erstellen, um Fotos in twonly zu importieren.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryNoImagesFound => 'Keine Bilder gefunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryNoImagesFoundDesc =>
|
||||||
|
'Es befinden sich keine Bilder auf deinem Gerät.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryShowAllImages => 'Alle Bilder anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryShowTwonlyAlbum => 'twonly-Album anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryToggleDescAll =>
|
||||||
|
'Es werden alle Bilder auf deinem Gerät angezeigt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryToggleDescTwonly =>
|
||||||
|
'Es wird das \"twonly\"-Album angezeigt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryFilterTwonly => 'Nur das twonly-Album anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryRefresh => 'Aktualisieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGallerySelectToImport =>
|
||||||
|
'Elemente zum Importieren auswählen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String importGalleryImportCount(num count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count Elemente importieren',
|
||||||
|
one: '1 Element importieren',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -746,6 +746,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get deleteOkBtnForAll => 'Delete for all';
|
String get deleteOkBtnForAll => 'Delete for all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String memoriesDeleteSnackbarSuccess(num count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'Deleted $count items successfully',
|
||||||
|
one: 'Deleted 1 item successfully',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get deleteOkBtnForMe => 'Delete for me';
|
String get deleteOkBtnForMe => 'Delete for me';
|
||||||
|
|
||||||
|
|
@ -755,6 +766,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get deleteImageBody => 'The image will be irrevocably deleted.';
|
String get deleteImageBody => 'The image will be irrevocably deleted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String deleteMemoriesBody(num count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'The $count images will be irrevocably deleted.',
|
||||||
|
one: 'The image will be irrevocably deleted.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsBackup => 'Backup';
|
String get settingsBackup => 'Backup';
|
||||||
|
|
||||||
|
|
@ -1972,4 +1994,119 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get unverifiedWarningButton => 'Verify now';
|
String get unverifiedWarningButton => 'Verify now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get today => 'Today';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get yesterday => 'Yesterday';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryDisableWarningTitle => 'Disable gallery saving?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryDisableWarningBody =>
|
||||||
|
'If you disable this, your media files will not be saved to your gallery and could be permanently lost if twonly is removed or has an issue, as media files are not yet backed up.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryDisableWarningConfirm => 'Disable';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageScanGalleryTitle => 'Import from Gallery';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryDeselectAll => 'Deselect all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGallerySelectAll => 'Select all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryPermissionRequired =>
|
||||||
|
'Permission to access your gallery is required to import previous twonly media files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String importGalleryPermissionError(Object error) {
|
||||||
|
return 'An error occurred while requesting permission: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String importGalleryLoadError(Object error) {
|
||||||
|
return 'Failed to load assets: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String importGalleryImportingOf(Object current, Object total) {
|
||||||
|
return 'Importing $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryStarting => 'Starting import...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String importGalleryComplete(
|
||||||
|
Object imported,
|
||||||
|
Object duplicated,
|
||||||
|
Object failed,
|
||||||
|
) {
|
||||||
|
return 'Import complete: $imported successfully imported, $duplicated duplicated and $failed failed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryGrantAccess => 'Grant Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryOpenSettings => 'Open Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryPermissionDenied =>
|
||||||
|
'Permission to access gallery denied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryTryAgain => 'Try Again';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryAlbumNotFound => '\"twonly\" album not found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryAlbumNotFoundDesc =>
|
||||||
|
'If you don\'t have this album yet, you can also create it to import photos into twonly.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryNoImagesFound => 'No images found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryNoImagesFoundDesc =>
|
||||||
|
'There are no images on your device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryShowAllImages => 'Show all images';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryShowTwonlyAlbum => 'Show twonly album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryToggleDescAll => 'Viewing all images on your device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryToggleDescTwonly => 'Viewing the \"twonly\" album.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryFilterTwonly => 'Only show the twonly-Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGalleryRefresh => 'Refresh';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importGallerySelectToImport => 'Select items to import';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String importGalleryImportCount(num count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'Import $count items',
|
||||||
|
one: 'Import 1 item',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit c33a4c3be99b38596abd0cfa91333db3a340dee2
|
Subproject commit 189bf8f4dbe2bee4f19a15b9640b8826e4f2e235
|
||||||
|
|
@ -13,7 +13,8 @@ class UserData {
|
||||||
required this.currentSetupPage,
|
required this.currentSetupPage,
|
||||||
required this.appVersion,
|
required this.appVersion,
|
||||||
});
|
});
|
||||||
factory UserData.fromJson(Map<String, dynamic> json) => _$UserDataFromJson(json);
|
factory UserData.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$UserDataFromJson(json);
|
||||||
|
|
||||||
final int userId;
|
final int userId;
|
||||||
|
|
||||||
|
|
@ -64,6 +65,9 @@ class UserData {
|
||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
bool requestedAudioPermission = false;
|
bool requestedAudioPermission = false;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: false)
|
||||||
|
bool enableDatabaseLogging = false;
|
||||||
|
|
||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
bool automaticallyMarkEqualMediaFilesAsOpened = false;
|
bool automaticallyMarkEqualMediaFilesAsOpened = false;
|
||||||
|
|
||||||
|
|
@ -83,8 +87,8 @@ class UserData {
|
||||||
|
|
||||||
Map<String, List<String>>? autoDownloadOptions;
|
Map<String, List<String>>? autoDownloadOptions;
|
||||||
|
|
||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: true)
|
||||||
bool storeMediaFilesInGallery = false;
|
bool storeMediaFilesInGallery = true;
|
||||||
|
|
||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
bool autoStoreAllSendUnlimitedMediaFiles = false;
|
bool autoStoreAllSendUnlimitedMediaFiles = false;
|
||||||
|
|
@ -186,7 +190,8 @@ class TwonlySafeBackup {
|
||||||
required this.backupId,
|
required this.backupId,
|
||||||
required this.encryptionKey,
|
required this.encryptionKey,
|
||||||
});
|
});
|
||||||
factory TwonlySafeBackup.fromJson(Map<String, dynamic> json) => _$TwonlySafeBackupFromJson(json);
|
factory TwonlySafeBackup.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$TwonlySafeBackupFromJson(json);
|
||||||
|
|
||||||
int lastBackupSize = 0;
|
int lastBackupSize = 0;
|
||||||
LastBackupUploadState backupUploadState = LastBackupUploadState.none;
|
LastBackupUploadState backupUploadState = LastBackupUploadState.none;
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
|
||||||
..defaultShowTime = (json['defaultShowTime'] as num?)?.toInt()
|
..defaultShowTime = (json['defaultShowTime'] as num?)?.toInt()
|
||||||
..requestedAudioPermission =
|
..requestedAudioPermission =
|
||||||
json['requestedAudioPermission'] as bool? ?? false
|
json['requestedAudioPermission'] as bool? ?? false
|
||||||
|
..enableDatabaseLogging = json['enableDatabaseLogging'] as bool? ?? false
|
||||||
..automaticallyMarkEqualMediaFilesAsOpened =
|
..automaticallyMarkEqualMediaFilesAsOpened =
|
||||||
json['automaticallyMarkEqualMediaFilesAsOpened'] as bool? ?? false
|
json['automaticallyMarkEqualMediaFilesAsOpened'] as bool? ?? false
|
||||||
..videoStabilizationEnabled =
|
..videoStabilizationEnabled =
|
||||||
|
|
@ -135,6 +136,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
|
||||||
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
|
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
|
||||||
'defaultShowTime': instance.defaultShowTime,
|
'defaultShowTime': instance.defaultShowTime,
|
||||||
'requestedAudioPermission': instance.requestedAudioPermission,
|
'requestedAudioPermission': instance.requestedAudioPermission,
|
||||||
|
'enableDatabaseLogging': instance.enableDatabaseLogging,
|
||||||
'automaticallyMarkEqualMediaFilesAsOpened':
|
'automaticallyMarkEqualMediaFilesAsOpened':
|
||||||
instance.automaticallyMarkEqualMediaFilesAsOpened,
|
instance.automaticallyMarkEqualMediaFilesAsOpened,
|
||||||
'videoStabilizationEnabled': instance.videoStabilizationEnabled,
|
'videoStabilizationEnabled': instance.videoStabilizationEnabled,
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,7 @@ import 'package:twonly/src/visual/views/settings/backup/backup_setup.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/chat/chat_reactions.view.dart';
|
import 'package:twonly/src/visual/views/settings/chat/chat_reactions.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/chat/chat_settings.view.dart';
|
import 'package:twonly/src/visual/views/settings/chat/chat_settings.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/data_and_storage.view.dart';
|
import 'package:twonly/src/visual/views/settings/data_and_storage.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/data_and_storage/export_media.view.dart';
|
import 'package:twonly/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/data_and_storage/import_media.view.dart';
|
|
||||||
import 'package:twonly/src/visual/views/settings/data_and_storage/manage_storage.view.dart';
|
import 'package:twonly/src/visual/views/settings/data_and_storage/manage_storage.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/developer/automated_testing.view.dart';
|
import 'package:twonly/src/visual/views/settings/developer/automated_testing.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/developer/developer.view.dart';
|
import 'package:twonly/src/visual/views/settings/developer/developer.view.dart';
|
||||||
|
|
@ -225,12 +224,8 @@ final routerProvider = GoRouter(
|
||||||
builder: (context, state) => const ManageStorageView(),
|
builder: (context, state) => const ManageStorageView(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'import',
|
path: 'import_gallery',
|
||||||
builder: (context, state) => const ImportMediaView(),
|
builder: (context, state) => const ImportFromGalleryView(),
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: 'export',
|
|
||||||
builder: (context, state) => const ExportMediaView(),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
25
lib/src/services/android_photo_picker.service.dart
Normal file
25
lib/src/services/android_photo_picker.service.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class AndroidPhotoPickerService {
|
||||||
|
static const MethodChannel _channel = MethodChannel('eu.twonly/photo_picker');
|
||||||
|
|
||||||
|
/// Launches the native Android Photo Picker and returns a list of URIs.
|
||||||
|
static Future<List<String>> pickImages() async {
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeListMethod<String>('pickImages');
|
||||||
|
return result ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the raw bytes from a content URI using the Android ContentResolver.
|
||||||
|
static Future<Uint8List?> getUriBytes(String uri) async {
|
||||||
|
try {
|
||||||
|
final bytes = await _channel.invokeMethod<Uint8List>('getUriBytes', {'uri': uri});
|
||||||
|
return bytes;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,10 @@ Future<void> handleReaction(
|
||||||
EncryptedContent_Reaction reaction,
|
EncryptedContent_Reaction reaction,
|
||||||
String receiptId,
|
String receiptId,
|
||||||
) async {
|
) async {
|
||||||
Log.info('[$receiptId] Got a reaction from $fromUserId (remove=${reaction.remove})');
|
Log.info(
|
||||||
|
'[$receiptId] Got a reaction from for ${reaction.targetMessageId} (remove=${reaction.remove})',
|
||||||
|
);
|
||||||
|
|
||||||
await twonlyDB.reactionsDao.updateReaction(
|
await twonlyDB.reactionsDao.updateReaction(
|
||||||
fromUserId,
|
fromUserId,
|
||||||
reaction.targetMessageId,
|
reaction.targetMessageId,
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ Future<void> reuploadMediaFiles() async {
|
||||||
|
|
||||||
final contacts = <int, Contact>{};
|
final contacts = <int, Contact>{};
|
||||||
|
|
||||||
for (var receipt in receipts) {
|
for (final receipt in receipts) {
|
||||||
if (receipt.retryCount > 1 && receipt.lastRetry != null) {
|
if (receipt.retryCount > 1 && receipt.lastRetry != null) {
|
||||||
final twentyFourHoursAgo = DateTime.now().subtract(
|
final twentyFourHoursAgo = DateTime.now().subtract(
|
||||||
const Duration(hours: 6),
|
const Duration(hours: 6),
|
||||||
|
|
@ -64,20 +64,6 @@ Future<void> reuploadMediaFiles() async {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (receipt.retryCount >= 2) {
|
|
||||||
// After two retries, change the receiptId. This addresses a bug where the receiver received the message and marked it as received, but the app was closed before the message was fully processed. Because the receipt was already stored, subsequent retries were detected as duplicates and rejected.
|
|
||||||
final oldReceiptId = receipt.receiptId;
|
|
||||||
final updatedReceipt = await twonlyDB.receiptsDao.rotateReceiptId(
|
|
||||||
oldReceiptId,
|
|
||||||
);
|
|
||||||
if (updatedReceipt == null) continue;
|
|
||||||
|
|
||||||
Log.info(
|
|
||||||
'Changed receiptId $oldReceiptId to ${updatedReceipt.receiptId} as retryCount is ${receipt.retryCount}',
|
|
||||||
);
|
|
||||||
receipt = updatedReceipt;
|
|
||||||
}
|
|
||||||
|
|
||||||
var messageId = receipt.messageId;
|
var messageId = receipt.messageId;
|
||||||
if (receipt.messageId == null) {
|
if (receipt.messageId == null) {
|
||||||
Log.info('Message not in receipt. Loading it from the content.');
|
Log.info('Message not in receipt. Loading it from the content.');
|
||||||
|
|
@ -413,6 +399,9 @@ Future<void> insertMediaFileInMessagesTable(
|
||||||
);
|
);
|
||||||
await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, clock.now());
|
await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, clock.now());
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
|
Log.info(
|
||||||
|
'Created message ${message.messageId} for media ${message.mediaId}',
|
||||||
|
);
|
||||||
// de-archive contact when sending a new message
|
// de-archive contact when sending a new message
|
||||||
await twonlyDB.groupsDao.updateGroup(
|
await twonlyDB.groupsDao.updateGroup(
|
||||||
message.groupId,
|
message.groupId,
|
||||||
|
|
@ -444,6 +433,10 @@ Future<void> _startBackgroundMediaUploadInternal(
|
||||||
|
|
||||||
if (mediaService.mediaFile.uploadState == UploadState.initialized ||
|
if (mediaService.mediaFile.uploadState == UploadState.initialized ||
|
||||||
mediaService.mediaFile.uploadState == UploadState.preprocessing) {
|
mediaService.mediaFile.uploadState == UploadState.preprocessing) {
|
||||||
|
Log.info(
|
||||||
|
'Hanlding media file ${mediaService.mediaFile.mediaId} in ${mediaService.mediaFile.uploadState}',
|
||||||
|
);
|
||||||
|
|
||||||
await mediaService.setUploadState(UploadState.preprocessing);
|
await mediaService.setUploadState(UploadState.preprocessing);
|
||||||
|
|
||||||
if (!mediaService.tempPath.existsSync()) {
|
if (!mediaService.tempPath.existsSync()) {
|
||||||
|
|
|
||||||
|
|
@ -148,8 +148,6 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({
|
||||||
message.encryptedContent,
|
message.encryptedContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
Log.info('Uploading message with receiptID ${receipt.receiptId}.');
|
|
||||||
|
|
||||||
Uint8List? pushData;
|
Uint8List? pushData;
|
||||||
if (receipt.retryCount == 0) {
|
if (receipt.retryCount == 0) {
|
||||||
final pushNotification = await getPushNotificationFromEncryptedContent(
|
final pushNotification = await getPushNotificationFromEncryptedContent(
|
||||||
|
|
@ -194,9 +192,12 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onlyReturnEncryptedData) {
|
if (onlyReturnEncryptedData) {
|
||||||
|
Log.info('Returning message with receiptID ${receipt.receiptId}.');
|
||||||
return (message.writeToBuffer(), pushData);
|
return (message.writeToBuffer(), pushData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.info('Uploading message with receiptID ${receipt.receiptId}.');
|
||||||
|
|
||||||
final resp = await apiService.sendTextMessage(
|
final resp = await apiService.sendTextMessage(
|
||||||
receipt.contactId,
|
receipt.contactId,
|
||||||
message.writeToBuffer(),
|
message.writeToBuffer(),
|
||||||
|
|
@ -350,7 +351,9 @@ Future<void> insertAndSendAskAboutUserMessage(
|
||||||
) async {
|
) async {
|
||||||
final directChat = await twonlyDB.groupsDao.createOrGetDirectChat(contactId);
|
final directChat = await twonlyDB.groupsDao.createOrGetDirectChat(contactId);
|
||||||
if (directChat == null) {
|
if (directChat == null) {
|
||||||
Log.error('Failed to get or create direct chat group for contact $contactId');
|
Log.error(
|
||||||
|
'Failed to get or create direct chat group for contact $contactId',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -483,6 +486,17 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (receipt != null) {
|
if (receipt != null) {
|
||||||
|
try {
|
||||||
|
final typeKeys = _getEncryptedContentTypes(encryptedContent);
|
||||||
|
Log.info(
|
||||||
|
'sendCipherText: type=[$typeKeys] messageId=$messageId receiptId=${receipt.receiptId}',
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
Log.info(
|
||||||
|
'sendCipherText: messageId=$messageId receiptId=${receipt.receiptId}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final tmp = tryToSendCompleteMessage(
|
final tmp = tryToSendCompleteMessage(
|
||||||
receipt: receipt,
|
receipt: receipt,
|
||||||
onlyReturnEncryptedData: onlyReturnEncryptedData,
|
onlyReturnEncryptedData: onlyReturnEncryptedData,
|
||||||
|
|
@ -568,3 +582,21 @@ Future<void> sendContactMyProfileData(int contactId) async {
|
||||||
);
|
);
|
||||||
await sendCipherText(contactId, encryptedContent, blocking: false);
|
await sendCipherText(contactId, encryptedContent, blocking: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getEncryptedContentTypes(pb.EncryptedContent content) {
|
||||||
|
final ignoredFields = {
|
||||||
|
'groupId',
|
||||||
|
'isDirectChat',
|
||||||
|
'senderProfileCounter',
|
||||||
|
'senderUserDiscoveryVersion',
|
||||||
|
};
|
||||||
|
|
||||||
|
final types = <String>[];
|
||||||
|
for (final field in content.info_.byName.values) {
|
||||||
|
if (content.hasField(field.tagNumber) &&
|
||||||
|
!ignoredFields.contains(field.name)) {
|
||||||
|
types.add(field.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types.join(', ');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
|
|
@ -72,6 +73,13 @@ class MediaFileService {
|
||||||
delete = false;
|
delete = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Never purge temp files while an upload is still in progress.
|
||||||
|
// The temp file is actively needed for encryption/upload.
|
||||||
|
if (mediaFile.uploadState != UploadState.uploaded &&
|
||||||
|
mediaFile.uploadState != UploadState.fileLimitReached) {
|
||||||
|
delete = false;
|
||||||
|
}
|
||||||
|
|
||||||
final messages = messageMap[mediaId] ?? [];
|
final messages = messageMap[mediaId] ?? [];
|
||||||
|
|
||||||
// in case messages in empty the file will be deleted, as delete is true by default
|
// in case messages in empty the file will be deleted, as delete is true by default
|
||||||
|
|
@ -303,6 +311,7 @@ class MediaFileService {
|
||||||
} else {
|
} else {
|
||||||
await saveImageToGallery(
|
await saveImageToGallery(
|
||||||
storedPath.readAsBytesSync(),
|
storedPath.readAsBytesSync(),
|
||||||
|
createdAt: mediaFile.createdAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -440,106 +449,41 @@ class MediaFileService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final bytes = storedPath.readAsBytesSync();
|
final bytes = storedPath.readAsBytesSync();
|
||||||
final image = img.decodeImage(bytes);
|
final result = await compute(_processImageCrop, bytes);
|
||||||
if (image == null) {
|
|
||||||
await twonlyDB.mediaFilesDao.updateMedia(
|
|
||||||
mediaFile.mediaId,
|
|
||||||
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var minY = 0;
|
if (result.isCropped && result.pngBytes != null) {
|
||||||
var maxY = image.height - 1;
|
try {
|
||||||
var minX = 0;
|
|
||||||
var maxX = image.width - 1;
|
|
||||||
|
|
||||||
var found = false;
|
|
||||||
for (var y = 0; y < image.height; y++) {
|
|
||||||
for (var x = 0; x < image.width; x++) {
|
|
||||||
if (image.getPixel(x, y).a > 10) {
|
|
||||||
minY = y;
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
found = false;
|
|
||||||
for (var y = image.height - 1; y >= minY; y--) {
|
|
||||||
for (var x = 0; x < image.width; x++) {
|
|
||||||
if (image.getPixel(x, y).a > 10) {
|
|
||||||
maxY = y;
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
found = false;
|
|
||||||
for (var x = 0; x < image.width; x++) {
|
|
||||||
for (var y = minY; y <= maxY; y++) {
|
|
||||||
if (image.getPixel(x, y).a > 10) {
|
|
||||||
minX = x;
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
found = false;
|
|
||||||
for (var x = image.width - 1; x >= minX; x--) {
|
|
||||||
for (var y = minY; y <= maxY; y++) {
|
|
||||||
if (image.getPixel(x, y).a > 10) {
|
|
||||||
maxX = x;
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
final newWidth = maxX - minX + 1;
|
|
||||||
final newHeight = maxY - minY + 1;
|
|
||||||
|
|
||||||
if (minY > 0 ||
|
|
||||||
maxY < image.height - 1 ||
|
|
||||||
minX > 0 ||
|
|
||||||
maxX < image.width - 1) {
|
|
||||||
if (newWidth > 10 && newHeight > 10) {
|
|
||||||
final cropped = img.copyCrop(
|
|
||||||
image,
|
|
||||||
x: minX,
|
|
||||||
y: minY,
|
|
||||||
width: newWidth,
|
|
||||||
height: newHeight,
|
|
||||||
);
|
|
||||||
final pngBytes = img.encodePng(cropped);
|
|
||||||
final webpBytes = await FlutterImageCompress.compressWithList(
|
final webpBytes = await FlutterImageCompress.compressWithList(
|
||||||
pngBytes,
|
result.pngBytes!,
|
||||||
format: CompressFormat.webp,
|
format: CompressFormat.webp,
|
||||||
quality: 90,
|
quality: 90,
|
||||||
);
|
);
|
||||||
storedPath.writeAsBytesSync(webpBytes);
|
|
||||||
|
|
||||||
if (thumbnailPath.existsSync()) {
|
if (webpBytes.isNotEmpty) {
|
||||||
thumbnailPath.deleteSync();
|
storedPath.writeAsBytesSync(webpBytes);
|
||||||
|
} else {
|
||||||
|
Log.warn('WebP compression returned empty, falling back to PNG');
|
||||||
|
storedPath.writeAsBytesSync(result.pngBytes!);
|
||||||
}
|
}
|
||||||
await createThumbnail();
|
} catch (e) {
|
||||||
final checksum = await sha256File(storedPath);
|
Log.error('Error compressing to WebP, falling back to PNG: $e');
|
||||||
await twonlyDB.mediaFilesDao.updateMedia(
|
storedPath.writeAsBytesSync(result.pngBytes!);
|
||||||
mediaFile.mediaId,
|
|
||||||
MediaFilesCompanion(
|
|
||||||
hasCropAnalyzed: const Value(true),
|
|
||||||
storedFileHash: Value(Uint8List.fromList(checksum)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await updateFromDB();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (thumbnailPath.existsSync()) {
|
||||||
|
thumbnailPath.deleteSync();
|
||||||
|
}
|
||||||
|
await createThumbnail();
|
||||||
|
final checksum = await sha256File(storedPath);
|
||||||
|
await twonlyDB.mediaFilesDao.updateMedia(
|
||||||
|
mediaFile.mediaId,
|
||||||
|
MediaFilesCompanion(
|
||||||
|
hasCropAnalyzed: const Value(true),
|
||||||
|
storedFileHash: Value(Uint8List.fromList(checksum)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await updateFromDB();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await twonlyDB.mediaFilesDao.updateMedia(
|
await twonlyDB.mediaFilesDao.updateMedia(
|
||||||
|
|
@ -559,3 +503,89 @@ class MediaFileService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CropResult {
|
||||||
|
const _CropResult(this.pngBytes, this.isCropped);
|
||||||
|
final Uint8List? pngBytes;
|
||||||
|
final bool isCropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
_CropResult _processImageCrop(Uint8List bytes) {
|
||||||
|
final image = img.decodeImage(bytes);
|
||||||
|
if (image == null) return const _CropResult(null, false);
|
||||||
|
|
||||||
|
var minY = 0;
|
||||||
|
var maxY = image.height - 1;
|
||||||
|
var minX = 0;
|
||||||
|
var maxX = image.width - 1;
|
||||||
|
|
||||||
|
var found = false;
|
||||||
|
for (var y = 0; y < image.height; y++) {
|
||||||
|
for (var x = 0; x < image.width; x++) {
|
||||||
|
if (image.getPixel(x, y).a > 10) {
|
||||||
|
minY = y;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
found = false;
|
||||||
|
for (var y = image.height - 1; y >= minY; y--) {
|
||||||
|
for (var x = 0; x < image.width; x++) {
|
||||||
|
if (image.getPixel(x, y).a > 10) {
|
||||||
|
maxY = y;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
found = false;
|
||||||
|
for (var x = 0; x < image.width; x++) {
|
||||||
|
for (var y = minY; y <= maxY; y++) {
|
||||||
|
if (image.getPixel(x, y).a > 10) {
|
||||||
|
minX = x;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
found = false;
|
||||||
|
for (var x = image.width - 1; x >= minX; x--) {
|
||||||
|
for (var y = minY; y <= maxY; y++) {
|
||||||
|
if (image.getPixel(x, y).a > 10) {
|
||||||
|
maxX = x;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newWidth = maxX - minX + 1;
|
||||||
|
final newHeight = maxY - minY + 1;
|
||||||
|
|
||||||
|
if (minY > 0 ||
|
||||||
|
maxY < image.height - 1 ||
|
||||||
|
minX > 0 ||
|
||||||
|
maxX < image.width - 1) {
|
||||||
|
if (newWidth > 10 && newHeight > 10) {
|
||||||
|
final cropped = img.copyCrop(
|
||||||
|
image,
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
);
|
||||||
|
final pngBytes = img.encodePng(cropped);
|
||||||
|
return _CropResult(pngBytes, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return const _CropResult(null, false);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:clock/clock.dart';
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
@ -145,37 +144,6 @@ Future<void> runMigrations() async {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userService.currentUser.appVersion < 116) {
|
|
||||||
// Because of a Bug in the handleMessagesOpened function, some messages where not marked as opened. So use the logs,
|
|
||||||
// to mark the files as opened.
|
|
||||||
final logs = await loadLogFile();
|
|
||||||
final openedMessages = logs.split(
|
|
||||||
'messages.c2c.dart:12 > Opened message [',
|
|
||||||
);
|
|
||||||
for (final opened in openedMessages) {
|
|
||||||
final messageIds = opened.split(']');
|
|
||||||
if (messageIds.isNotEmpty) {
|
|
||||||
final now = clock.now();
|
|
||||||
for (final messageId in messageIds.first.split(',')) {
|
|
||||||
await (twonlyDB.update(
|
|
||||||
twonlyDB.messages,
|
|
||||||
)..where(
|
|
||||||
(tbl) =>
|
|
||||||
tbl.messageId.equals(messageId) &
|
|
||||||
(tbl.openedByAll.isNull() | tbl.openedAt.isNull()),
|
|
||||||
))
|
|
||||||
.write(
|
|
||||||
MessagesCompanion(
|
|
||||||
openedAt: Value(now),
|
|
||||||
openedByAll: Value(now),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await UserService.update((u) => u.appVersion = 116);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
assert(
|
assert(
|
||||||
AppState.latestAppVersionId == 116,
|
AppState.latestAppVersionId == 116,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,13 @@ class UserService {
|
||||||
if (userDataMap != null) {
|
if (userDataMap != null) {
|
||||||
final userData = UserData.fromJson(userDataMap);
|
final userData = UserData.fromJson(userDataMap);
|
||||||
await RustKeyManager.setUserId(userId: userData.userId);
|
await RustKeyManager.setUserId(userId: userData.userId);
|
||||||
|
try {
|
||||||
|
// Ensure that the old userData is removed as it breaks the backup mechanism.
|
||||||
|
// This code can be removed when all users have updated to the latest version...
|
||||||
|
await SecureStorage.instance.delete(key: 'userData');
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Could not delete user data from SecureStorage: $e');
|
||||||
|
}
|
||||||
return userData;
|
return userData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,15 +65,20 @@ class UserService {
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> _migrateFromSecureStorage(UserData userData) async {
|
static Future<void> _migrateFromSecureStorage(UserData userData) async {
|
||||||
// Currently empty migration logic as requested, but we MUST store the data
|
|
||||||
await KeyValueStore.put('user', userData.toJson());
|
await KeyValueStore.put('user', userData.toJson());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await RustKeyManager.setUserId(userId: userData.userId);
|
await RustKeyManager.setUserId(userId: userData.userId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.error('Could not set userId in RustKeyManager during migration: $e');
|
Log.error('Could not set userId in RustKeyManager during migration: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: Log migration
|
try {
|
||||||
|
await SecureStorage.instance.delete(key: 'userData');
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Could not delete user data from SecureStorage: $e');
|
||||||
|
}
|
||||||
|
|
||||||
Log.info('Migrated user data from SecureStorage to KeyValueStore');
|
Log.info('Migrated user data from SecureStorage to KeyValueStore');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:convert/convert.dart';
|
import 'package:convert/convert.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
@ -7,6 +8,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:gal/gal.dart';
|
import 'package:gal/gal.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:local_auth/local_auth.dart';
|
import 'package:local_auth/local_auth.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
@ -31,19 +33,49 @@ extension ShortCutsExtension on BuildContext {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> saveImageToGallery(Uint8List imageBytes) async {
|
Future<String?> saveImageToGallery(
|
||||||
|
Uint8List imageBytes, {
|
||||||
|
DateTime? createdAt,
|
||||||
|
}) async {
|
||||||
|
var bytesToProcess = imageBytes;
|
||||||
|
|
||||||
|
if (createdAt != null) {
|
||||||
|
try {
|
||||||
|
final image = img.decodeImage(imageBytes);
|
||||||
|
if (image != null) {
|
||||||
|
final formattedDate = DateFormat(
|
||||||
|
'yyyy:MM:dd HH:mm:ss',
|
||||||
|
).format(createdAt);
|
||||||
|
image.exif.imageIfd[0x0132] = img.IfdValueAscii(
|
||||||
|
formattedDate,
|
||||||
|
); // DateTime
|
||||||
|
image.exif.exifIfd[0x9003] = img.IfdValueAscii(
|
||||||
|
formattedDate,
|
||||||
|
); // DateTimeOriginal
|
||||||
|
image.exif.exifIfd[0x9004] = img.IfdValueAscii(
|
||||||
|
formattedDate,
|
||||||
|
); // DateTimeDigitized
|
||||||
|
|
||||||
|
bytesToProcess = img.encodeJpg(image);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final jpgImages = await FlutterImageCompress.compressWithList(
|
final jpgImages = await FlutterImageCompress.compressWithList(
|
||||||
// ignore: avoid_redundant_argument_values
|
// ignore: avoid_redundant_argument_values
|
||||||
format: CompressFormat.jpeg,
|
format: CompressFormat.jpeg,
|
||||||
imageBytes,
|
bytesToProcess,
|
||||||
quality: 100,
|
quality: 100,
|
||||||
|
keepExif: true,
|
||||||
);
|
);
|
||||||
final hasAccess = await Gal.hasAccess();
|
final hasAccess = await Gal.hasAccess(toAlbum: true);
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
await Gal.requestAccess();
|
await Gal.requestAccess(toAlbum: true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await Gal.putImageBytes(jpgImages);
|
await Gal.putImageBytes(jpgImages, album: 'twonly');
|
||||||
return null;
|
return null;
|
||||||
} on GalException catch (e) {
|
} on GalException catch (e) {
|
||||||
Log.error(e);
|
Log.error(e);
|
||||||
|
|
@ -52,12 +84,12 @@ Future<String?> saveImageToGallery(Uint8List imageBytes) async {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> saveVideoToGallery(String videoPath) async {
|
Future<String?> saveVideoToGallery(String videoPath) async {
|
||||||
final hasAccess = await Gal.hasAccess();
|
final hasAccess = await Gal.hasAccess(toAlbum: true);
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
await Gal.requestAccess();
|
await Gal.requestAccess(toAlbum: true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await Gal.putVideo(videoPath);
|
await Gal.putVideo(videoPath, album: 'twonly');
|
||||||
return null;
|
return null;
|
||||||
} on GalException catch (e) {
|
} on GalException catch (e) {
|
||||||
Log.error(e);
|
Log.error(e);
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class StartupGuard {
|
||||||
final stat = file.statSync();
|
final stat = file.statSync();
|
||||||
final diff = DateTime.now().difference(stat.modified);
|
final diff = DateTime.now().difference(stat.modified);
|
||||||
|
|
||||||
final starting = diff.inSeconds < 30;
|
final starting = diff.inSeconds < 5;
|
||||||
if (starting) {
|
if (starting) {
|
||||||
Log.info(
|
Log.info(
|
||||||
'Startup guard: App is currently starting (${diff.inSeconds}s ago).',
|
'Startup guard: App is currently starting (${diff.inSeconds}s ago).',
|
||||||
|
|
|
||||||
77
lib/src/visual/components/selectable_thumbnail.comp.dart
Normal file
77
lib/src/visual/components/selectable_thumbnail.comp.dart
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
|
||||||
|
class SelectableThumbnailComp extends StatelessWidget {
|
||||||
|
const SelectableThumbnailComp({
|
||||||
|
required this.child,
|
||||||
|
required this.isSelected,
|
||||||
|
this.selectionMode = true,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final bool isSelected;
|
||||||
|
final bool selectionMode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? context.color.primary : Colors.transparent,
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black12,
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
margin: EdgeInsets.all(isSelected ? 4 : 0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
isSelected ? 12 : 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
child,
|
||||||
|
if (selectionMode)
|
||||||
|
Positioned(
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? context.color.primary
|
||||||
|
: Colors.black38,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).brightness == Brightness.dark
|
||||||
|
? Colors.white
|
||||||
|
: Colors.black,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: isSelected
|
||||||
|
? const Icon(
|
||||||
|
Icons.check,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
)
|
||||||
|
: const SizedBox(width: 14, height: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,7 +54,7 @@ class _VideoPlayerFileHelperState extends State<VideoPlayerFileHelper> {
|
||||||
aspectRatio: _controller.value.aspectRatio,
|
aspectRatio: _controller.value.aspectRatio,
|
||||||
child: VideoPlayerHelper(controller: _controller),
|
child: VideoPlayerHelper(controller: _controller),
|
||||||
)
|
)
|
||||||
: const CircularProgressIndicator(),
|
: const CircularProgressIndicator.adaptive(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,7 @@ class _StartNewChatView extends State<AddNewShortcutView> {
|
||||||
group: group,
|
group: group,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
),
|
),
|
||||||
trailing: Checkbox(
|
trailing: Checkbox.adaptive(
|
||||||
value: _selectedGroups.contains(group.groupId),
|
value: _selectedGroups.contains(group.groupId),
|
||||||
side: WidgetStateBorderSide.resolveWith(
|
side: WidgetStateBorderSide.resolveWith(
|
||||||
(states) {
|
(states) {
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ class CameraScannedOverlay extends StatelessWidget {
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 12,
|
width: 12,
|
||||||
height: 12,
|
height: 12,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ColoredBox(
|
ColoredBox(
|
||||||
|
|
|
||||||
|
|
@ -63,23 +63,25 @@ class CameraPreviewControllerView extends StatefulWidget {
|
||||||
final bool hideControllers;
|
final bool hideControllers;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CameraPreviewControllerView> createState() => _CameraPreviewControllerViewState();
|
State<CameraPreviewControllerView> createState() =>
|
||||||
|
_CameraPreviewControllerViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CameraPreviewControllerViewState extends State<CameraPreviewControllerView> {
|
class _CameraPreviewControllerViewState
|
||||||
|
extends State<CameraPreviewControllerView> {
|
||||||
Future<bool>? _permissionsFuture;
|
Future<bool>? _permissionsFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (!AppState.hasCameraPermissions) {
|
_permissionsFuture = checkPermissions().then((hasPermission) {
|
||||||
_permissionsFuture = checkPermissions().then((hasPermission) {
|
if (hasPermission && mounted) {
|
||||||
if (hasPermission) {
|
setState(() {
|
||||||
AppState.hasCameraPermissions = true;
|
AppState.hasCameraPermissions = true;
|
||||||
}
|
});
|
||||||
return hasPermission;
|
}
|
||||||
});
|
return hasPermission;
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -107,6 +109,10 @@ class _CameraPreviewControllerViewState extends State<CameraPreviewControllerVie
|
||||||
} else {
|
} else {
|
||||||
return PermissionHandlerView(
|
return PermissionHandlerView(
|
||||||
onSuccess: () {
|
onSuccess: () {
|
||||||
|
setState(() {
|
||||||
|
AppState.hasCameraPermissions = true;
|
||||||
|
_permissionsFuture = Future.value(true);
|
||||||
|
});
|
||||||
widget.mainController.selectCamera(0, true);
|
widget.mainController.selectCamera(0, true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -212,6 +218,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
// Maybe this is the reason?
|
// Maybe this is the reason?
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
await androidVolumeDownSub?.cancel();
|
||||||
androidVolumeDownSub = FlutterAndroidVolumeKeydown.stream.listen((
|
androidVolumeDownSub = FlutterAndroidVolumeKeydown.stream.listen((
|
||||||
event,
|
event,
|
||||||
) {
|
) {
|
||||||
|
|
@ -233,13 +240,15 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
}
|
}
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
await androidVolumeDownSub?.cancel();
|
await androidVolumeDownSub?.cancel();
|
||||||
|
androidVolumeDownSub = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initAsync() async {
|
Future<void> initAsync() async {
|
||||||
_hasAudioPermission = await Permission.microphone.isGranted;
|
_hasAudioPermission = await Permission.microphone.isGranted;
|
||||||
|
|
||||||
if (!_hasAudioPermission && !userService.currentUser.requestedAudioPermission) {
|
if (!_hasAudioPermission &&
|
||||||
|
!userService.currentUser.requestedAudioPermission) {
|
||||||
await UserService.update((u) => u.requestedAudioPermission = true);
|
await UserService.update((u) => u.requestedAudioPermission = true);
|
||||||
await requestMicrophonePermission();
|
await requestMicrophonePermission();
|
||||||
}
|
}
|
||||||
|
|
@ -260,7 +269,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateScaleFactor(double newScale) async {
|
Future<void> updateScaleFactor(double newScale) async {
|
||||||
if (mc.selectedCameraDetails.scaleFactor == newScale || mc.cameraController == null) {
|
if (mc.selectedCameraDetails.scaleFactor == newScale ||
|
||||||
|
mc.cameraController == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await mc.cameraController?.setZoomLevel(
|
await mc.cameraController?.setZoomLevel(
|
||||||
|
|
@ -343,7 +353,9 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
bool sharedFromGallery = false,
|
bool sharedFromGallery = false,
|
||||||
MediaType? mediaType,
|
MediaType? mediaType,
|
||||||
}) async {
|
}) async {
|
||||||
final type = mediaType ?? ((videoFilePath != null) ? MediaType.video : MediaType.image);
|
final type =
|
||||||
|
mediaType ??
|
||||||
|
((videoFilePath != null) ? MediaType.video : MediaType.image);
|
||||||
final mediaFileService = await initializeMediaUpload(
|
final mediaFileService = await initializeMediaUpload(
|
||||||
type,
|
type,
|
||||||
userService.currentUser.defaultShowTime,
|
userService.currentUser.defaultShowTime,
|
||||||
|
|
@ -384,9 +396,10 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
mainCameraController: mc,
|
mainCameraController: mc,
|
||||||
previewLink: mc.sharedLinkForPreview,
|
previewLink: mc.sharedLinkForPreview,
|
||||||
),
|
),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder:
|
||||||
return child;
|
(context, animation, secondaryAnimation, child) {
|
||||||
},
|
return child;
|
||||||
|
},
|
||||||
transitionDuration: Duration.zero,
|
transitionDuration: Duration.zero,
|
||||||
reverseTransitionDuration: Duration.zero,
|
reverseTransitionDuration: Duration.zero,
|
||||||
),
|
),
|
||||||
|
|
@ -416,13 +429,16 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get isFront => mc.cameraController?.description.lensDirection == CameraLensDirection.front;
|
bool get isFront =>
|
||||||
|
mc.cameraController?.description.lensDirection ==
|
||||||
|
CameraLensDirection.front;
|
||||||
|
|
||||||
Future<void> onPanUpdate(dynamic details) async {
|
Future<void> onPanUpdate(dynamic details) async {
|
||||||
if (details == null) {
|
if (details == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mc.cameraController == null || !mc.cameraController!.value.isInitialized) {
|
if (mc.cameraController == null ||
|
||||||
|
!mc.cameraController!.value.isInitialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -551,7 +567,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startVideoRecording() async {
|
Future<void> startVideoRecording() async {
|
||||||
if (mc.cameraController != null && mc.cameraController!.value.isRecordingVideo) {
|
if (mc.cameraController != null &&
|
||||||
|
mc.cameraController!.value.isRecordingVideo) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -571,7 +588,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
_currentTime = clock.now();
|
_currentTime = clock.now();
|
||||||
});
|
});
|
||||||
if (_videoRecordingStarted != null &&
|
if (_videoRecordingStarted != null &&
|
||||||
_currentTime.difference(_videoRecordingStarted!).inSeconds >= maxVideoRecordingTime) {
|
_currentTime.difference(_videoRecordingStarted!).inSeconds >=
|
||||||
|
maxVideoRecordingTime) {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
_videoRecordingTimer = null;
|
_videoRecordingTimer = null;
|
||||||
stopVideoRecording();
|
stopVideoRecording();
|
||||||
|
|
@ -608,7 +626,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
_videoRecordingLocked = false;
|
_videoRecordingLocked = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mc.cameraController == null || !mc.cameraController!.value.isRecordingVideo) {
|
if (mc.cameraController == null ||
|
||||||
|
!mc.cameraController!.value.isRecordingVideo) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -636,7 +655,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (mc.selectedCameraDetails.cameraId >= AppEnvironment.cameras.length || mc.cameraController == null) {
|
if (mc.selectedCameraDetails.cameraId >= AppEnvironment.cameras.length ||
|
||||||
|
mc.cameraController == null) {
|
||||||
return Container();
|
return Container();
|
||||||
}
|
}
|
||||||
return StreamBuilder(
|
return StreamBuilder(
|
||||||
|
|
@ -660,7 +680,9 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
_baseScaleFactor = mc.selectedCameraDetails.scaleFactor;
|
_baseScaleFactor = mc.selectedCameraDetails.scaleFactor;
|
||||||
});
|
});
|
||||||
// Get the position of the pointer
|
// Get the position of the pointer
|
||||||
final renderBox = keyTriggerButton.currentContext!.findRenderObject()! as RenderBox;
|
final renderBox =
|
||||||
|
keyTriggerButton.currentContext!.findRenderObject()!
|
||||||
|
as RenderBox;
|
||||||
final localPosition = renderBox.globalToLocal(
|
final localPosition = renderBox.globalToLocal(
|
||||||
details.globalPosition,
|
details.globalPosition,
|
||||||
);
|
);
|
||||||
|
|
@ -696,18 +718,24 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null && !mc.isVideoRecording)
|
if (!mc.isSharePreviewIsShown &&
|
||||||
|
widget.sendToGroup != null &&
|
||||||
|
!mc.isVideoRecording)
|
||||||
ShowTitleText(
|
ShowTitleText(
|
||||||
title: widget.sendToGroup!.groupName,
|
title: widget.sendToGroup!.groupName,
|
||||||
desc: context.lang.cameraPreviewSendTo,
|
desc: context.lang.cameraPreviewSendTo,
|
||||||
),
|
),
|
||||||
if (!mc.isSharePreviewIsShown && mc.sharedLinkForPreview != null && !mc.isVideoRecording)
|
if (!mc.isSharePreviewIsShown &&
|
||||||
|
mc.sharedLinkForPreview != null &&
|
||||||
|
!mc.isVideoRecording)
|
||||||
ShowTitleText(
|
ShowTitleText(
|
||||||
title: mc.sharedLinkForPreview?.host ?? '',
|
title: mc.sharedLinkForPreview?.host ?? '',
|
||||||
desc: 'Link',
|
desc: 'Link',
|
||||||
isLink: true,
|
isLink: true,
|
||||||
),
|
),
|
||||||
if (!mc.isSharePreviewIsShown && !mc.isVideoRecording && !widget.hideControllers)
|
if (!mc.isSharePreviewIsShown &&
|
||||||
|
!mc.isVideoRecording &&
|
||||||
|
!widget.hideControllers)
|
||||||
CameraTopActions(
|
CameraTopActions(
|
||||||
selectedCameraDetails: mc.selectedCameraDetails,
|
selectedCameraDetails: mc.selectedCameraDetails,
|
||||||
hasAudioPermission: _hasAudioPermission,
|
hasAudioPermission: _hasAudioPermission,
|
||||||
|
|
@ -751,7 +779,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
videoRecordingStarted: _videoRecordingStarted,
|
videoRecordingStarted: _videoRecordingStarted,
|
||||||
maxVideoRecordingTime: maxVideoRecordingTime,
|
maxVideoRecordingTime: maxVideoRecordingTime,
|
||||||
),
|
),
|
||||||
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null || widget.hideControllers)
|
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null ||
|
||||||
|
widget.hideControllers)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 5,
|
left: 5,
|
||||||
top: 10,
|
top: 10,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// ignore_for_file: avoid_dynamic_calls
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
@ -24,31 +24,56 @@ Future<bool> checkPermissions() async {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
class PermissionHandlerViewState extends State<PermissionHandlerView> {
|
class PermissionHandlerViewState extends State<PermissionHandlerView>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
Timer? _timer;
|
||||||
|
bool _isSuccessTriggered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_timer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
||||||
|
await _checkAndTriggerSuccess();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_checkAndTriggerSuccess();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkAndTriggerSuccess() async {
|
||||||
|
if (_isSuccessTriggered) return;
|
||||||
|
try {
|
||||||
|
if (await checkPermissions()) {
|
||||||
|
_isSuccessTriggered = true;
|
||||||
|
_timer?.cancel();
|
||||||
|
// ignore: avoid_dynamic_calls
|
||||||
|
widget.onSuccess();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<Permission, PermissionStatus>> permissionServices() async {
|
Future<Map<Permission, PermissionStatus>> permissionServices() async {
|
||||||
// try {
|
|
||||||
final statuses = await [
|
final statuses = await [
|
||||||
Permission.camera,
|
Permission.camera,
|
||||||
// Permission.microphone,
|
|
||||||
Permission.notification,
|
Permission.notification,
|
||||||
].request();
|
].request();
|
||||||
// } catch (e) {}
|
|
||||||
// You can request multiple permissions at once.
|
|
||||||
|
|
||||||
// if (statuses[Permission.microphone]!.isPermanentlyDenied) {
|
|
||||||
// openAppSettings();
|
|
||||||
// // setState(() {});
|
|
||||||
// } else {
|
|
||||||
// // if (statuses[Permission.microphone]!.isDenied) {
|
|
||||||
// // }
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (statuses[Permission.camera]!.isPermanentlyDenied) {
|
if (statuses[Permission.camera]!.isPermanentlyDenied) {
|
||||||
await openAppSettings();
|
await openAppSettings();
|
||||||
// setState(() {});
|
|
||||||
} else {
|
|
||||||
// if (statuses[Permission.camera]!.isDenied) {
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return statuses;
|
return statuses;
|
||||||
|
|
@ -75,6 +100,7 @@ class PermissionHandlerViewState extends State<PermissionHandlerView> {
|
||||||
try {
|
try {
|
||||||
await permissionServices();
|
await permissionServices();
|
||||||
if (await checkPermissions()) {
|
if (await checkPermissions()) {
|
||||||
|
// ignore: avoid_dynamic_calls
|
||||||
widget.onSuccess();
|
widget.onSuccess();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ class SaveToGalleryButtonState extends State<SaveToGalleryButton> {
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 12,
|
width: 12,
|
||||||
height: 12,
|
height: 12,
|
||||||
child: CircularProgressIndicator(strokeWidth: 1),
|
child: CircularProgressIndicator.adaptive(strokeWidth: 1),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
_imageSaved
|
_imageSaved
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ class _ShareImageView extends State<ShareImageView> {
|
||||||
),
|
),
|
||||||
Transform.scale(
|
Transform.scale(
|
||||||
scale: 0.75,
|
scale: 0.75,
|
||||||
child: Checkbox(
|
child: Checkbox.adaptive(
|
||||||
value: !hideArchivedUsers,
|
value: !hideArchivedUsers,
|
||||||
side: WidgetStateBorderSide.resolveWith(
|
side: WidgetStateBorderSide.resolveWith(
|
||||||
(states) {
|
(states) {
|
||||||
|
|
@ -293,9 +293,9 @@ class _ShareImageView extends State<ShareImageView> {
|
||||||
? SizedBox(
|
? SizedBox(
|
||||||
height: 12,
|
height: 12,
|
||||||
width: 12,
|
width: 12,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: Theme.of(context).colorScheme.inversePrimary,
|
valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const FaIcon(FontAwesomeIcons.solidPaperPlane),
|
: const FaIcon(FontAwesomeIcons.solidPaperPlane),
|
||||||
|
|
@ -382,7 +382,7 @@ class UserList extends StatelessWidget {
|
||||||
group: group,
|
group: group,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
),
|
),
|
||||||
trailing: Checkbox(
|
trailing: Checkbox.adaptive(
|
||||||
value: selectedGroupIds.contains(group.groupId),
|
value: selectedGroupIds.contains(group.groupId),
|
||||||
side: WidgetStateBorderSide.resolveWith(
|
side: WidgetStateBorderSide.resolveWith(
|
||||||
(states) {
|
(states) {
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ class UserCheckbox extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(child: Container()),
|
Expanded(child: Container()),
|
||||||
Checkbox(
|
Checkbox.adaptive(
|
||||||
value: isChecked,
|
value: isChecked,
|
||||||
side: WidgetStateBorderSide.resolveWith(
|
side: WidgetStateBorderSide.resolveWith(
|
||||||
(states) {
|
(states) {
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ class _SelectShowTimeState extends State<SelectShowTime> {
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Checkbox(
|
Checkbox.adaptive(
|
||||||
value: _storeAsDefault,
|
value: _storeAsDefault,
|
||||||
onChanged: (value) => setState(() {
|
onChanged: (value) => setState(() {
|
||||||
_storeAsDefault = !_storeAsDefault;
|
_storeAsDefault = !_storeAsDefault;
|
||||||
|
|
|
||||||
|
|
@ -717,11 +717,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
? SizedBox(
|
? SizedBox(
|
||||||
height: 12,
|
height: 12,
|
||||||
width: 12,
|
width: 12,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: Theme.of(
|
valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary),
|
||||||
context,
|
|
||||||
).colorScheme.inversePrimary,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const FaIcon(FontAwesomeIcons.solidPaperPlane),
|
: const FaIcon(FontAwesomeIcons.solidPaperPlane),
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ class _DrawLayerState extends State<DrawLayer> {
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: RotatedBox(
|
child: RotatedBox(
|
||||||
quarterTurns: 1,
|
quarterTurns: 1,
|
||||||
child: Slider(
|
child: Slider.adaptive(
|
||||||
value: _sliderValue,
|
value: _sliderValue,
|
||||||
thumbColor: currentColor,
|
thumbColor: currentColor,
|
||||||
activeColor: Colors.transparent,
|
activeColor: Colors.transparent,
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ class TwitterPostCard extends StatelessWidget {
|
||||||
height: 150,
|
height: 150,
|
||||||
color: const Color(0xFFF5F8FA),
|
color: const Color(0xFFF5F8FA),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
valueColor: AlwaysStoppedAnimation(twitterBlue),
|
valueColor: AlwaysStoppedAnimation(twitterBlue),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||||
import 'package:twonly/src/visual/themes/colors.dart';
|
import 'package:twonly/src/visual/themes/colors.dart';
|
||||||
|
import 'package:twonly/src/visual/views/chats/chat_messages_components/animated_new_message.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/blink.component.dart';
|
import 'package:twonly/src/visual/views/chats/chat_messages_components/blink.component.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_group_action.dart';
|
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_group_action.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.dart';
|
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.dart';
|
||||||
|
|
@ -40,6 +41,11 @@ class ChatMessagesView extends StatefulWidget {
|
||||||
class _ChatMessagesViewState extends State<ChatMessagesView>
|
class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
HashSet<int> alreadyReportedOpened = HashSet<int>();
|
HashSet<int> alreadyReportedOpened = HashSet<int>();
|
||||||
|
|
||||||
|
bool _hasReceivedFirstMessageBatch = false;
|
||||||
|
final HashSet<String> _knownMessageIds = HashSet<String>();
|
||||||
|
final HashSet<String> _animateMessageIds = HashSet<String>();
|
||||||
|
|
||||||
StreamSubscription<Group?>? userSub;
|
StreamSubscription<Group?>? userSub;
|
||||||
StreamSubscription<List<Message>>? messageSub;
|
StreamSubscription<List<Message>>? messageSub;
|
||||||
StreamSubscription<List<GroupHistory>>? groupActionsSub;
|
StreamSubscription<List<GroupHistory>>? groupActionsSub;
|
||||||
|
|
@ -131,6 +137,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
allMessages = update;
|
allMessages = update;
|
||||||
await protectMessageUpdating.protect(() async {
|
await protectMessageUpdating.protect(() async {
|
||||||
await setMessages(update, groupActions);
|
await setMessages(update, groupActions);
|
||||||
|
_hasReceivedFirstMessageBatch = true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -161,6 +168,15 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
await flutterLocalNotificationsPlugin.cancelAll();
|
await flutterLocalNotificationsPlugin.cancelAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (final msg in newMessages) {
|
||||||
|
if (_hasReceivedFirstMessageBatch &&
|
||||||
|
!_knownMessageIds.contains(msg.messageId) &&
|
||||||
|
msg.senderId == null) {
|
||||||
|
_animateMessageIds.add(msg.messageId);
|
||||||
|
}
|
||||||
|
_knownMessageIds.add(msg.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
final chatItems = <ChatItem>[];
|
final chatItems = <ChatItem>[];
|
||||||
final storedMediaFiles = <Message>[];
|
final storedMediaFiles = <Message>[];
|
||||||
|
|
||||||
|
|
@ -337,24 +353,32 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
} else {
|
} else {
|
||||||
final chatMessage = messages[i].message!;
|
final chatMessage = messages[i].message!;
|
||||||
return BlinkWidget(
|
return BlinkWidget(
|
||||||
|
key: Key('blink_${chatMessage.messageId}'),
|
||||||
enabled: focusedScrollItem == i,
|
enabled: focusedScrollItem == i,
|
||||||
child: ChatListEntry(
|
child: AnimatedNewMessage(
|
||||||
key: Key(chatMessage.messageId),
|
key: Key('anim_${chatMessage.messageId}'),
|
||||||
message: messages[i].message!,
|
messageId: chatMessage.messageId,
|
||||||
nextMessage: (i > 0) ? messages[i - 1].message : null,
|
animateIds: _animateMessageIds,
|
||||||
prevMessage: ((i + 1) < messages.length)
|
child: ChatListEntry(
|
||||||
? messages[i + 1].message
|
key: Key(chatMessage.messageId),
|
||||||
: null,
|
message: messages[i].message!,
|
||||||
group: group,
|
nextMessage: (i > 0)
|
||||||
galleryItems: galleryItems,
|
? messages[i - 1].message
|
||||||
userIdToContact: userIdToContact,
|
: null,
|
||||||
scrollToMessage: scrollToMessage,
|
prevMessage: ((i + 1) < messages.length)
|
||||||
onResponseTriggered: () {
|
? messages[i + 1].message
|
||||||
setState(() {
|
: null,
|
||||||
quotesMessage = chatMessage;
|
group: group,
|
||||||
});
|
galleryItems: galleryItems,
|
||||||
textFieldFocus?.requestFocus();
|
userIdToContact: userIdToContact,
|
||||||
},
|
scrollToMessage: scrollToMessage,
|
||||||
|
onResponseTriggered: () {
|
||||||
|
setState(() {
|
||||||
|
quotesMessage = chatMessage;
|
||||||
|
});
|
||||||
|
textFieldFocus?.requestFocus();
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AnimatedNewMessage extends StatefulWidget {
|
||||||
|
const AnimatedNewMessage({
|
||||||
|
required this.child,
|
||||||
|
required this.messageId,
|
||||||
|
required this.animateIds,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final String messageId;
|
||||||
|
final Set<String> animateIds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AnimatedNewMessage> createState() => _AnimatedNewMessageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnimatedNewMessageState extends State<AnimatedNewMessage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _scaleAnimation;
|
||||||
|
late Animation<double> _opacityAnimation;
|
||||||
|
bool _didAnimate = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_scaleAnimation =
|
||||||
|
Tween<double>(
|
||||||
|
begin: 0,
|
||||||
|
end: 1,
|
||||||
|
).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.decelerate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_opacityAnimation =
|
||||||
|
Tween<double>(
|
||||||
|
begin: 0,
|
||||||
|
end: 1,
|
||||||
|
).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (widget.animateIds.contains(widget.messageId)) {
|
||||||
|
widget.animateIds.remove(widget.messageId);
|
||||||
|
_didAnimate = true;
|
||||||
|
_controller.forward();
|
||||||
|
} else {
|
||||||
|
_controller.value = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_didAnimate) {
|
||||||
|
return widget.child;
|
||||||
|
}
|
||||||
|
return SizeTransition(
|
||||||
|
sizeFactor: CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
),
|
||||||
|
axisAlignment: 1,
|
||||||
|
child: ScaleTransition(
|
||||||
|
scale: _scaleAnimation,
|
||||||
|
alignment: Alignment.bottomRight,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: _opacityAnimation,
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -190,7 +190,7 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -302,7 +302,7 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 12,
|
width: 12,
|
||||||
height: 12,
|
height: 12,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ class _ContactRowState extends State<_ContactRow> {
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
Colors.white,
|
Colors.white,
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,24 @@ class ChatDateChip extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final formattedDate =
|
final now = DateTime.now();
|
||||||
'${DateFormat.Hm(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}\n${DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}';
|
final date = item.date!;
|
||||||
|
final locale = Localizations.localeOf(context).toLanguageTag();
|
||||||
|
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final yesterday = today.subtract(const Duration(days: 1));
|
||||||
|
final itemDay = DateTime(date.year, date.month, date.day);
|
||||||
|
|
||||||
|
String formattedDate;
|
||||||
|
if (itemDay == today) {
|
||||||
|
formattedDate = context.lang.today;
|
||||||
|
} else if (itemDay == yesterday) {
|
||||||
|
formattedDate = context.lang.yesterday;
|
||||||
|
} else if (date.year == now.year) {
|
||||||
|
formattedDate = DateFormat('E, d. MMM', locale).format(date);
|
||||||
|
} else {
|
||||||
|
formattedDate = DateFormat('E, d. MMM y', locale).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,8 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
) async {
|
) async {
|
||||||
if (widget.textFieldFocus.hasFocus &&
|
if (widget.textFieldFocus.hasFocus &&
|
||||||
_lastTextChangeTime != null &&
|
_lastTextChangeTime != null &&
|
||||||
DateTime.now().difference(_lastTextChangeTime!) <= const Duration(seconds: 6)) {
|
DateTime.now().difference(_lastTextChangeTime!) <=
|
||||||
|
const Duration(seconds: 6)) {
|
||||||
await sendTypingIndication(widget.group.groupId, true);
|
await sendTypingIndication(widget.group.groupId, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -210,7 +211,9 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
|
|
||||||
Future<void> _loadContactId() async {
|
Future<void> _loadContactId() async {
|
||||||
if (widget.group.isDirectChat) {
|
if (widget.group.isDirectChat) {
|
||||||
final members = await twonlyDB.groupsDao.getGroupContact(widget.group.groupId);
|
final members = await twonlyDB.groupsDao.getGroupContact(
|
||||||
|
widget.group.groupId,
|
||||||
|
);
|
||||||
if (members.isNotEmpty && mounted) {
|
if (members.isNotEmpty && mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_contactId = members.first.userId;
|
_contactId = members.first.userId;
|
||||||
|
|
@ -240,18 +243,14 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
UnverifiedContactWarningComp(
|
UnverifiedContactWarningComp(
|
||||||
group: widget.group,
|
group: widget.group,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(left: 10, bottom: 10),
|
||||||
bottom: 10,
|
|
||||||
left: 10,
|
|
||||||
top: 5,
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
// padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 3,
|
// horizontal: 3,
|
||||||
),
|
// ),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.color.surfaceContainer,
|
color: context.color.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
|
@ -281,7 +280,9 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
),
|
),
|
||||||
child: FaIcon(
|
child: FaIcon(
|
||||||
size: 20,
|
size: 20,
|
||||||
_emojiShowing ? FontAwesomeIcons.keyboard : FontAwesomeIcons.faceSmile,
|
_emojiShowing
|
||||||
|
? FontAwesomeIcons.keyboard
|
||||||
|
: FontAwesomeIcons.faceSmile,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -293,7 +294,8 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
controller: _textFieldController,
|
controller: _textFieldController,
|
||||||
focusNode: widget.textFieldFocus,
|
focusNode: widget.textFieldFocus,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
showCursor: _recordingState != RecordingState.recording,
|
showCursor:
|
||||||
|
_recordingState != RecordingState.recording,
|
||||||
maxLines: 4,
|
maxLines: 4,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
|
|
@ -344,16 +346,21 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
_currentDuration,
|
_currentDuration,
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isDarkMode(context) ? Colors.white : Colors.black,
|
color: isDarkMode(context)
|
||||||
|
? Colors.white
|
||||||
|
: Colors.black,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!_audioRecordingLock) ...[
|
if (!_audioRecordingLock) ...[
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: (100 - _cancelSlideOffset) % 101,
|
width:
|
||||||
|
(100 - _cancelSlideOffset) % 101,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
context.lang.voiceMessageSlideToCancel,
|
context
|
||||||
|
.lang
|
||||||
|
.voiceMessageSlideToCancel,
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -394,13 +401,17 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onLongPressMoveUpdate: (details) {
|
onLongPressMoveUpdate: (details) {
|
||||||
if (_audioRecordingLock) return;
|
if (_audioRecordingLock) return;
|
||||||
if (_recordingOffset.dy - details.localPosition.dy >= 100) {
|
if (_recordingOffset.dy -
|
||||||
|
details.localPosition.dy >=
|
||||||
|
100) {
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.heavyImpact();
|
||||||
setState(() {
|
setState(() {
|
||||||
_audioRecordingLock = true;
|
_audioRecordingLock = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (_recordingOffset.dx - details.localPosition.dx >= 90 &&
|
if (_recordingOffset.dx -
|
||||||
|
details.localPosition.dx >=
|
||||||
|
90 &&
|
||||||
_recordingState == RecordingState.recording) {
|
_recordingState == RecordingState.recording) {
|
||||||
_recordingState = RecordingState.none;
|
_recordingState = RecordingState.none;
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.heavyImpact();
|
||||||
|
|
@ -408,9 +419,13 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
final a = _recordingOffset.dx - details.localPosition.dx;
|
final a =
|
||||||
|
_recordingOffset.dx -
|
||||||
|
details.localPosition.dx;
|
||||||
if (a > 0 && a <= 90) {
|
if (a > 0 && a <= 90) {
|
||||||
_cancelSlideOffset = _recordingOffset.dx - details.localPosition.dx;
|
_cancelSlideOffset =
|
||||||
|
_recordingOffset.dx -
|
||||||
|
details.localPosition.dx;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -430,7 +445,9 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
if (_recordingState == RecordingState.recording && !_audioRecordingLock)
|
if (_recordingState ==
|
||||||
|
RecordingState.recording &&
|
||||||
|
!_audioRecordingLock)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
top: -120,
|
top: -120,
|
||||||
left: -5,
|
left: -5,
|
||||||
|
|
@ -440,8 +457,12 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
padding: const EdgeInsets.only(top: 13),
|
padding: const EdgeInsets.only(top: 13),
|
||||||
height: 60,
|
height: 60,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(90),
|
borderRadius: BorderRadius.circular(
|
||||||
color: isDarkMode(context) ? Colors.black : Colors.white,
|
90,
|
||||||
|
),
|
||||||
|
color: isDarkMode(context)
|
||||||
|
? Colors.black
|
||||||
|
: Colors.white,
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -461,7 +482,9 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_recordingState == RecordingState.recording && !_audioRecordingLock)
|
if (_recordingState ==
|
||||||
|
RecordingState.recording &&
|
||||||
|
!_audioRecordingLock)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
top: -20,
|
top: -20,
|
||||||
left: -25,
|
left: -25,
|
||||||
|
|
@ -488,10 +511,15 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
),
|
),
|
||||||
child: FaIcon(
|
child: FaIcon(
|
||||||
size: 20,
|
size: 20,
|
||||||
color: (_recordingState == RecordingState.recording) ? Colors.white : null,
|
color:
|
||||||
|
(_recordingState ==
|
||||||
|
RecordingState.recording)
|
||||||
|
? Colors.white
|
||||||
|
: null,
|
||||||
(_recordingState == RecordingState.none)
|
(_recordingState == RecordingState.none)
|
||||||
? FontAwesomeIcons.microphone
|
? FontAwesomeIcons.microphone
|
||||||
: (_recordingState == RecordingState.recording)
|
: (_recordingState ==
|
||||||
|
RecordingState.recording)
|
||||||
? FontAwesomeIcons.stop
|
? FontAwesomeIcons.stop
|
||||||
: FontAwesomeIcons.play,
|
: FontAwesomeIcons.play,
|
||||||
),
|
),
|
||||||
|
|
@ -511,7 +539,9 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
color: context.color.primary,
|
color: context.color.primary,
|
||||||
FontAwesomeIcons.solidPaperPlane,
|
FontAwesomeIcons.solidPaperPlane,
|
||||||
),
|
),
|
||||||
onPressed: _audioRecordingLock ? _stopAudioRecording : _sendMessage,
|
onPressed: _audioRecordingLock
|
||||||
|
? _stopAudioRecording
|
||||||
|
: _sendMessage,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
|
||||||
|
|
@ -24,15 +24,15 @@ enum MessageSendState {
|
||||||
|
|
||||||
MessageSendState messageSendStateFromMessage(Message msg) {
|
MessageSendState messageSendStateFromMessage(Message msg) {
|
||||||
if (msg.senderId == null) {
|
if (msg.senderId == null) {
|
||||||
|
if (msg.openedByAll != null || msg.openedAt != null) {
|
||||||
|
return MessageSendState.sendOpened;
|
||||||
|
}
|
||||||
|
|
||||||
/// messages was send by me, look up if every messages was received by the server...
|
/// messages was send by me, look up if every messages was received by the server...
|
||||||
if (msg.ackByServer == null) {
|
if (msg.ackByServer == null) {
|
||||||
return MessageSendState.sending;
|
return MessageSendState.sending;
|
||||||
}
|
}
|
||||||
if (msg.openedAt != null) {
|
return MessageSendState.send;
|
||||||
return MessageSendState.sendOpened;
|
|
||||||
} else {
|
|
||||||
return MessageSendState.send;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// message received
|
// message received
|
||||||
|
|
@ -76,7 +76,7 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
child: CircularProgressIndicator(strokeWidth: 1, color: color),
|
child: CircularProgressIndicator.adaptive(strokeWidth: 1, valueColor: AlwaysStoppedAnimation(color)),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 2),
|
const SizedBox(width: 2),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,16 @@ class UnverifiedContactWarningComp extends StatelessWidget {
|
||||||
return StreamBuilder<void>(
|
return StreamBuilder<void>(
|
||||||
stream: userService.onUserUpdated,
|
stream: userService.onUserUpdated,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
if (!userService.currentUser.securityProfile.showWarningForNonVerifiedContacts) {
|
if (!userService
|
||||||
|
.currentUser
|
||||||
|
.securityProfile
|
||||||
|
.showWarningForNonVerifiedContacts) {
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
return StreamBuilder<VerificationStatus>(
|
return StreamBuilder<VerificationStatus>(
|
||||||
stream: twonlyDB.keyVerificationDao.watchAllGroupMembersVerified(group.groupId),
|
stream: twonlyDB.keyVerificationDao.watchAllGroupMembersVerified(
|
||||||
|
group.groupId,
|
||||||
|
),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final status = snapshot.data;
|
final status = snapshot.data;
|
||||||
if (status == null || status == VerificationStatus.trusted) {
|
if (status == null || status == VerificationStatus.trusted) {
|
||||||
|
|
@ -39,7 +44,9 @@ class UnverifiedContactWarningComp extends StatelessWidget {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.color.errorContainer.withValues(alpha: 0.5),
|
color: context.color.errorContainer.withValues(alpha: 0.5),
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
border: Border.all(color: context.color.error.withValues(alpha: 0.5)),
|
border: Border.all(
|
||||||
|
color: context.color.error.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -93,14 +100,23 @@ class UnverifiedContactWarningComp extends StatelessWidget {
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: context.color.onErrorContainer,
|
backgroundColor: context.color.onErrorContainer,
|
||||||
foregroundColor: context.color.errorContainer,
|
foregroundColor: context.color.errorContainer,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
textStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
horizontal: 10,
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (group.isDirectChat) {
|
if (group.isDirectChat) {
|
||||||
await context.push(Routes.settingsHelpFaqVerifyBadge);
|
await context.push(
|
||||||
|
Routes.settingsHelpFaqVerifyBadge,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await context.push(Routes.profileGroup(group.groupId));
|
await context.push(
|
||||||
|
Routes.profileGroup(group.groupId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(context.lang.unverifiedWarningButton),
|
child: Text(context.lang.unverifiedWarningButton),
|
||||||
|
|
@ -109,7 +125,12 @@ class UnverifiedContactWarningComp extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child,
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 5,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
child: CircularProgressIndicator(strokeWidth: 1),
|
child: CircularProgressIndicator.adaptive(strokeWidth: 1),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
imageSaved
|
imageSaved
|
||||||
|
|
@ -573,7 +573,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () {
|
||||||
if (!showShortReactions) {
|
if (!showShortReactions) {
|
||||||
displayShortReactions();
|
displayShortReactions();
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -67,26 +67,24 @@ class _EmojiReactionWidgetState extends State<EmojiReactionWidget> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedSize(
|
return GestureDetector(
|
||||||
key: _targetKey,
|
key: _targetKey,
|
||||||
duration: const Duration(milliseconds: 200),
|
onTap: () async {
|
||||||
curve: Curves.linearToEaseOut,
|
await sendReaction(widget.groupId, widget.messageId, widget.emoji);
|
||||||
child: GestureDetector(
|
widget.emojiKey.currentState?.spawn(
|
||||||
onTap: () async {
|
getGlobalOffset(_targetKey),
|
||||||
await sendReaction(widget.groupId, widget.messageId, widget.emoji);
|
widget.emoji,
|
||||||
widget.emojiKey.currentState?.spawn(
|
);
|
||||||
getGlobalOffset(_targetKey),
|
widget.hide();
|
||||||
widget.emoji,
|
},
|
||||||
);
|
child: SizedBox(
|
||||||
widget.hide();
|
width: 40,
|
||||||
},
|
child: Center(
|
||||||
child: SizedBox(
|
child: widget.show
|
||||||
width: widget.show ? 40 : 10,
|
? EmojiAnimationComp(
|
||||||
child: Center(
|
emoji: widget.emoji,
|
||||||
child: EmojiAnimationComp(
|
)
|
||||||
emoji: widget.emoji,
|
: const SizedBox.shrink(),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class ReactionButtons extends StatefulWidget {
|
||||||
class _ReactionButtonsState extends State<ReactionButtons> {
|
class _ReactionButtonsState extends State<ReactionButtons> {
|
||||||
int selectedShortReaction = -1;
|
int selectedShortReaction = -1;
|
||||||
final GlobalKey _keyEmojiPicker = GlobalKey();
|
final GlobalKey _keyEmojiPicker = GlobalKey();
|
||||||
|
bool _renderAnimations = false;
|
||||||
|
|
||||||
List<String> selectedEmojis = EmojiAnimationComp.animatedIcons.keys
|
List<String> selectedEmojis = EmojiAnimationComp.animatedIcons.keys
|
||||||
.toList()
|
.toList()
|
||||||
|
|
@ -47,9 +48,28 @@ class _ReactionButtonsState extends State<ReactionButtons> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_renderAnimations = widget.show;
|
||||||
initAsync();
|
initAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(ReactionButtons oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.show != oldWidget.show) {
|
||||||
|
if (widget.show) {
|
||||||
|
_renderAnimations = true;
|
||||||
|
} else {
|
||||||
|
Future.delayed(const Duration(milliseconds: 150), () {
|
||||||
|
if (mounted && !widget.show) {
|
||||||
|
setState(() {
|
||||||
|
_renderAnimations = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> initAsync() async {
|
Future<void> initAsync() async {
|
||||||
if (userService.currentUser.preSelectedEmojies != null) {
|
if (userService.currentUser.preSelectedEmojies != null) {
|
||||||
selectedEmojis = userService.currentUser.preSelectedEmojies!;
|
selectedEmojis = userService.currentUser.preSelectedEmojies!;
|
||||||
|
|
@ -71,91 +91,92 @@ class _ReactionButtonsState extends State<ReactionButtons> {
|
||||||
? 50
|
? 50
|
||||||
: widget.mediaViewerDistanceFromBottom)
|
: widget.mediaViewerDistanceFromBottom)
|
||||||
: widget.mediaViewerDistanceFromBottom - 20,
|
: widget.mediaViewerDistanceFromBottom - 20,
|
||||||
left: widget.show ? 0 : MediaQuery.sizeOf(context).width / 2,
|
left: 0,
|
||||||
right: widget.show ? 0 : MediaQuery.sizeOf(context).width / 2,
|
right: 0,
|
||||||
curve: Curves.linearToEaseOut,
|
curve: Curves.linearToEaseOut,
|
||||||
child: AnimatedOpacity(
|
child: IgnorePointer(
|
||||||
opacity: widget.show ? 1.0 : 0.0, // Fade in/out
|
ignoring: !widget.show,
|
||||||
duration: const Duration(milliseconds: 150),
|
child: AnimatedOpacity(
|
||||||
child: Container(
|
opacity: widget.show ? 1.0 : 0.0, // Fade in/out
|
||||||
color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent,
|
duration: const Duration(milliseconds: 150),
|
||||||
padding: widget.show
|
child: Container(
|
||||||
? const EdgeInsets.symmetric(vertical: 32)
|
color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent,
|
||||||
: null,
|
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (secondRowEmojis.isNotEmpty)
|
if (secondRowEmojis.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: secondRowEmojis
|
||||||
|
.map(
|
||||||
|
(emoji) => EmojiReactionWidget(
|
||||||
|
messageId: widget.messageId,
|
||||||
|
groupId: widget.groupId,
|
||||||
|
hide: widget.hide,
|
||||||
|
show: _renderAnimations,
|
||||||
|
emoji: emoji as String,
|
||||||
|
emojiKey: widget.emojiKey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
if (secondRowEmojis.isNotEmpty) const SizedBox(height: 15),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: secondRowEmojis
|
children: [
|
||||||
.map(
|
...firstRowEmojis.map(
|
||||||
(emoji) => EmojiReactionWidget(
|
(emoji) => EmojiReactionWidget(
|
||||||
messageId: widget.messageId,
|
messageId: widget.messageId,
|
||||||
groupId: widget.groupId,
|
groupId: widget.groupId,
|
||||||
hide: widget.hide,
|
hide: widget.hide,
|
||||||
show: widget.show,
|
show: _renderAnimations,
|
||||||
emoji: emoji as String,
|
emoji: emoji,
|
||||||
emojiKey: widget.emojiKey,
|
emojiKey: widget.emojiKey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
key: _keyEmojiPicker,
|
||||||
|
onTap: () async {
|
||||||
|
final layer =
|
||||||
|
// ignore: inference_failure_on_function_invocation
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: context.color.surface,
|
||||||
|
builder: (context) {
|
||||||
|
return const EmojiPickerBottom();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
as EmojiLayerData?;
|
||||||
|
if (layer == null) return;
|
||||||
|
await sendReaction(
|
||||||
|
widget.groupId,
|
||||||
|
widget.messageId,
|
||||||
|
layer.text,
|
||||||
|
);
|
||||||
|
widget.emojiKey.currentState?.spawn(
|
||||||
|
getGlobalOffset(_keyEmojiPicker),
|
||||||
|
layer.text,
|
||||||
|
);
|
||||||
|
widget.hide();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.color.surfaceContainer.withAlpha(100),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
)
|
padding: const EdgeInsets.all(8),
|
||||||
.toList(),
|
child: const FaIcon(
|
||||||
|
FontAwesomeIcons.ellipsisVertical,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (secondRowEmojis.isNotEmpty) const SizedBox(height: 15),
|
],
|
||||||
Row(
|
),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
...firstRowEmojis.map(
|
|
||||||
(emoji) => EmojiReactionWidget(
|
|
||||||
messageId: widget.messageId,
|
|
||||||
groupId: widget.groupId,
|
|
||||||
hide: widget.hide,
|
|
||||||
show: widget.show,
|
|
||||||
emoji: emoji,
|
|
||||||
emojiKey: widget.emojiKey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GestureDetector(
|
|
||||||
key: _keyEmojiPicker,
|
|
||||||
onTap: () async {
|
|
||||||
final layer =
|
|
||||||
// ignore: inference_failure_on_function_invocation
|
|
||||||
await showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
backgroundColor: context.color.surface,
|
|
||||||
builder: (context) {
|
|
||||||
return const EmojiPickerBottom();
|
|
||||||
},
|
|
||||||
)
|
|
||||||
as EmojiLayerData?;
|
|
||||||
if (layer == null) return;
|
|
||||||
await sendReaction(
|
|
||||||
widget.groupId,
|
|
||||||
widget.messageId,
|
|
||||||
layer.text,
|
|
||||||
);
|
|
||||||
widget.emojiKey.currentState?.spawn(
|
|
||||||
getGlobalOffset(_keyEmojiPicker),
|
|
||||||
layer.text,
|
|
||||||
);
|
|
||||||
widget.hide();
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.color.surfaceContainer.withAlpha(100),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
child: const FaIcon(
|
|
||||||
FontAwesomeIcons.ellipsisVertical,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ class _AddContactViaQrLinkViewState extends State<AddContactViaQrLinkView> {
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@ class _SearchUsernameView extends State<AddNewUserView> {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ class FriendSuggestionsComp extends StatelessWidget {
|
||||||
final contact = f.$1;
|
final contact = f.$1;
|
||||||
final isSelected =
|
final isSelected =
|
||||||
selectedFriends.contains(contact.userId);
|
selectedFriends.contains(contact.userId);
|
||||||
return CheckboxListTile(
|
return CheckboxListTile.adaptive(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text(contact.displayName ?? contact.username),
|
title: Text(contact.displayName ?? contact.username),
|
||||||
value: isSelected,
|
value: isSelected,
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@ class _MutualGroupsExpansionTileCompState
|
||||||
shape: const RoundedRectangleBorder(),
|
shape: const RoundedRectangleBorder(),
|
||||||
backgroundColor: context.color.surfaceContainer,
|
backgroundColor: context.color.surfaceContainer,
|
||||||
collapsedShape: const RoundedRectangleBorder(),
|
collapsedShape: const RoundedRectangleBorder(),
|
||||||
initiallyExpanded: _groups.length < 5,
|
|
||||||
onExpansionChanged: (expanded) {
|
onExpansionChanged: (expanded) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ class UserDiscoveryContactSettingsComp extends StatelessWidget {
|
||||||
icon: FontAwesomeIcons.usersViewfinder,
|
icon: FontAwesomeIcons.usersViewfinder,
|
||||||
text: context.lang.userDiscoverySettingsTitle,
|
text: context.lang.userDiscoverySettingsTitle,
|
||||||
onTap: () => context.navPush(const UserDiscoverySettingsView()),
|
onTap: () => context.navPush(const UserDiscoverySettingsView()),
|
||||||
subtitle: !contact.userDiscoveryExcluded &&
|
subtitle:
|
||||||
|
!contact.userDiscoveryExcluded &&
|
||||||
contact.mediaSendCounter <
|
contact.mediaSendCounter <
|
||||||
userService.currentUser.requiredSendImages
|
userService.currentUser.requiredSendImages
|
||||||
? Text(
|
? Text(
|
||||||
|
|
@ -66,7 +67,7 @@ class UserDiscoveryContactSettingsComp extends StatelessWidget {
|
||||||
: null,
|
: null,
|
||||||
trailing: Transform.scale(
|
trailing: Transform.scale(
|
||||||
scale: 0.8,
|
scale: 0.8,
|
||||||
child: Switch(
|
child: Switch.adaptive(
|
||||||
value: !contact.userDiscoveryExcluded,
|
value: !contact.userDiscoveryExcluded,
|
||||||
onChanged: (a) async {
|
onChanged: (a) async {
|
||||||
await UserDiscoveryService.changeExclusionForContact(
|
await UserDiscoveryService.changeExclusionForContact(
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ class _GroupCreateSelectGroupNameViewState
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 15,
|
width: 15,
|
||||||
height: 15,
|
height: 15,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ class _StartNewChatView extends State<GroupCreateSelectMembersView> {
|
||||||
contactId: user.userId,
|
contactId: user.userId,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
trailing: Checkbox(
|
trailing: Checkbox.adaptive(
|
||||||
value:
|
value:
|
||||||
selectedUsers.contains(user.userId) |
|
selectedUsers.contains(user.userId) |
|
||||||
alreadyInGroup.contains(user.userId),
|
alreadyInGroup.contains(user.userId),
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/model/memory_item.model.dart';
|
import 'package:twonly/src/model/memory_item.model.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/memories/components/memory_transition_painter.dart';
|
import 'package:twonly/src/visual/views/memories/components/memory_transition_painter.dart';
|
||||||
|
|
||||||
class MemoriesThumbnailComp extends StatefulWidget {
|
class MemoriesThumbnailComp extends StatefulWidget {
|
||||||
const MemoriesThumbnailComp({
|
const MemoriesThumbnailComp({
|
||||||
required this.galleryItem,
|
required this.galleryItem,
|
||||||
|
|
@ -166,115 +165,60 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
|
||||||
scale: _scaleAnimation,
|
scale: _scaleAnimation,
|
||||||
child: FadeTransition(
|
child: FadeTransition(
|
||||||
opacity: _scaleController,
|
opacity: _scaleController,
|
||||||
child: AnimatedContainer(
|
child: SelectableThumbnailComp(
|
||||||
duration: const Duration(milliseconds: 200),
|
isSelected: widget.isSelected,
|
||||||
curve: Curves.easeInOut,
|
selectionMode: widget.selectionMode,
|
||||||
decoration: BoxDecoration(
|
child: Stack(
|
||||||
color: widget.isSelected
|
fit: StackFit.expand,
|
||||||
? context.color.primary
|
children: [
|
||||||
: Colors.transparent,
|
if (cachedInfo != null)
|
||||||
boxShadow: const [
|
RawImage(
|
||||||
BoxShadow(
|
image: cachedInfo.image,
|
||||||
color: Colors.black12,
|
fit: BoxFit.cover,
|
||||||
blurRadius: 4,
|
)
|
||||||
offset: Offset(0, 2),
|
else if (_imageProvider != null)
|
||||||
),
|
Image(
|
||||||
],
|
image: _imageProvider!,
|
||||||
),
|
fit: BoxFit.cover,
|
||||||
child: AnimatedContainer(
|
gaplessPlayback: true,
|
||||||
duration: const Duration(milliseconds: 200),
|
)
|
||||||
curve: Curves.easeInOut,
|
else
|
||||||
margin: EdgeInsets.all(widget.isSelected ? 4 : 0),
|
ColoredBox(
|
||||||
decoration: BoxDecoration(
|
color: Colors.grey.shade200,
|
||||||
borderRadius: BorderRadius.circular(
|
child: const Center(
|
||||||
widget.isSelected ? 12 : 0,
|
child: FaIcon(
|
||||||
),
|
FontAwesomeIcons.image,
|
||||||
),
|
color: Colors.black26,
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
if (cachedInfo != null)
|
|
||||||
RawImage(
|
|
||||||
image: cachedInfo.image,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
)
|
|
||||||
else if (_imageProvider != null)
|
|
||||||
Image(
|
|
||||||
image: _imageProvider!,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
gaplessPlayback: true,
|
|
||||||
)
|
|
||||||
else
|
|
||||||
ColoredBox(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
child: const Center(
|
|
||||||
child: FaIcon(
|
|
||||||
FontAwesomeIcons.image,
|
|
||||||
color: Colors.black26,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (isVideo)
|
if (isVideo)
|
||||||
const Positioned.fill(
|
const Positioned.fill(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: FaIcon(
|
child: FaIcon(
|
||||||
FontAwesomeIcons.circlePlay,
|
FontAwesomeIcons.circlePlay,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 32,
|
size: 32,
|
||||||
shadows: [
|
|
||||||
Shadow(color: Colors.black54, blurRadius: 6),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (widget.selectionMode)
|
|
||||||
Positioned(
|
|
||||||
top: 6,
|
|
||||||
right: 6,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: widget.isSelected
|
|
||||||
? context.color.primary
|
|
||||||
: Colors.black38,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color:
|
|
||||||
Theme.of(context).brightness ==
|
|
||||||
Brightness.dark
|
|
||||||
? Colors.white
|
|
||||||
: Colors.black,
|
|
||||||
width: 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: widget.isSelected
|
|
||||||
? const Icon(
|
|
||||||
Icons.check,
|
|
||||||
size: 14,
|
|
||||||
color: Colors.white,
|
|
||||||
)
|
|
||||||
: const SizedBox(width: 14, height: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (media.mediaFile.isFavorite)
|
|
||||||
const Positioned(
|
|
||||||
bottom: 6,
|
|
||||||
left: 6,
|
|
||||||
child: Icon(
|
|
||||||
Icons.favorite,
|
|
||||||
color: Colors.redAccent,
|
|
||||||
size: 16,
|
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(color: Colors.black54, blurRadius: 4),
|
Shadow(color: Colors.black54, blurRadius: 6),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
if (media.mediaFile.isFavorite)
|
||||||
|
const Positioned(
|
||||||
|
bottom: 6,
|
||||||
|
left: 6,
|
||||||
|
child: Icon(
|
||||||
|
Icons.favorite,
|
||||||
|
color: Colors.redAccent,
|
||||||
|
size: 16,
|
||||||
|
shadows: [
|
||||||
|
Shadow(color: Colors.black54, blurRadius: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,9 @@ class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: Colors.white,
|
valueColor: AlwaysStoppedAnimation(Colors.white),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const FaIcon(
|
: const FaIcon(
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ class MemoriesViewState extends State<MemoriesView> {
|
||||||
final confirmed = await showAlertDialog(
|
final confirmed = await showAlertDialog(
|
||||||
context,
|
context,
|
||||||
context.lang.deleteImageTitle,
|
context.lang.deleteImageTitle,
|
||||||
context.lang.deleteImageBody,
|
context.lang.deleteMemoriesBody(count),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
@ -219,7 +219,7 @@ class MemoriesViewState extends State<MemoriesView> {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showSnackbar(
|
showSnackbar(
|
||||||
context,
|
context,
|
||||||
'Deleted $count items successfully',
|
context.lang.memoriesDeleteSnackbarSuccess(count),
|
||||||
level: SnackbarLevel.success,
|
level: SnackbarLevel.success,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -239,7 +239,7 @@ class MemoriesViewState extends State<MemoriesView> {
|
||||||
} else if (media.mediaFile.type == MediaType.image ||
|
} else if (media.mediaFile.type == MediaType.image ||
|
||||||
media.mediaFile.type == MediaType.gif) {
|
media.mediaFile.type == MediaType.gif) {
|
||||||
final imageBytes = await media.storedPath.readAsBytes();
|
final imageBytes = await media.storedPath.readAsBytes();
|
||||||
await saveImageToGallery(imageBytes);
|
await saveImageToGallery(imageBytes, createdAt: media.mediaFile.createdAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -354,7 +354,7 @@ class MemoriesViewState extends State<MemoriesView> {
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
labelBuilder: (offset) {
|
labelBuilder: (offset) {
|
||||||
final state = _service.currentState;
|
final state = _service.currentState;
|
||||||
if (state.isEmpty) return null;
|
if (state.isEmpty || state.months.isEmpty) return null;
|
||||||
|
|
||||||
// Simple heuristic to find month by offset
|
// Simple heuristic to find month by offset
|
||||||
double currentOffset = 56;
|
double currentOffset = 56;
|
||||||
|
|
@ -409,7 +409,9 @@ class MemoriesViewState extends State<MemoriesView> {
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value: state.migrationProgress,
|
value: state.migrationProgress,
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
color: context.color.primary,
|
valueColor: AlwaysStoppedAnimation(
|
||||||
|
context.color.primary,
|
||||||
|
),
|
||||||
backgroundColor: context.color.primary
|
backgroundColor: context.color.primary
|
||||||
.withValues(alpha: 0.2),
|
.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
|
|
@ -487,8 +489,10 @@ class MemoriesViewState extends State<MemoriesView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SliverPadding(
|
SliverPadding(
|
||||||
padding: EdgeInsets.only(bottom: 32),
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).padding.bottom + 150,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ class _SynchronizedImageViewerScreenState
|
||||||
} else if (item.mediaFile.type == MediaType.image ||
|
} else if (item.mediaFile.type == MediaType.image ||
|
||||||
item.mediaFile.type == MediaType.gif) {
|
item.mediaFile.type == MediaType.gif) {
|
||||||
final imageBytes = await item.storedPath.readAsBytes();
|
final imageBytes = await item.storedPath.readAsBytes();
|
||||||
await saveImageToGallery(imageBytes);
|
await saveImageToGallery(imageBytes, createdAt: item.mediaFile.createdAt);
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showSnackbar(
|
showSnackbar(
|
||||||
|
|
|
||||||
|
|
@ -219,8 +219,8 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 24,
|
height: 24,
|
||||||
width: 24,
|
width: 24,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
color: Colors.white,
|
valueColor: AlwaysStoppedAnimation(Colors.white),
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -299,8 +299,8 @@ class _RegisterViewState extends State<RegisterView> {
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
color: Colors.white,
|
valueColor: AlwaysStoppedAnimation(Colors.white),
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ class NextButtonComp extends StatelessWidget {
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 24,
|
height: 24,
|
||||||
width: 24,
|
width: 24,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class SetupSwitchCard extends StatelessWidget {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile(
|
SwitchListTile.adaptive(
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
title: Text(
|
title: Text(
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ class _RecoveryViewState extends State<RecoveryView> {
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 16,
|
height: 16,
|
||||||
width: 16,
|
width: 16,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.restore_rounded),
|
: const Icon(Icons.restore_rounded),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ class _AppearanceViewState extends State<AppearanceView> {
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(context.lang.contactUsShortcut),
|
title: Text(context.lang.contactUsShortcut),
|
||||||
onTap: toggleShowFeedbackIcon,
|
onTap: toggleShowFeedbackIcon,
|
||||||
trailing: Switch(
|
trailing: Switch.adaptive(
|
||||||
value: !userService.currentUser.showFeedbackShortcut,
|
value: !userService.currentUser.showFeedbackShortcut,
|
||||||
onChanged: (a) => toggleShowFeedbackIcon(),
|
onChanged: (a) => toggleShowFeedbackIcon(),
|
||||||
),
|
),
|
||||||
|
|
@ -123,7 +123,7 @@ class _AppearanceViewState extends State<AppearanceView> {
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(context.lang.startWithCameraOpen),
|
title: Text(context.lang.startWithCameraOpen),
|
||||||
onTap: toggleStartWithCameraOpen,
|
onTap: toggleStartWithCameraOpen,
|
||||||
trailing: Switch(
|
trailing: Switch.adaptive(
|
||||||
value: userService.currentUser.startWithCameraOpen,
|
value: userService.currentUser.startWithCameraOpen,
|
||||||
onChanged: (a) => toggleStartWithCameraOpen(),
|
onChanged: (a) => toggleStartWithCameraOpen(),
|
||||||
),
|
),
|
||||||
|
|
@ -131,7 +131,7 @@ class _AppearanceViewState extends State<AppearanceView> {
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(context.lang.showImagePreviewWhenSending),
|
title: Text(context.lang.showImagePreviewWhenSending),
|
||||||
onTap: toggleShowImagePreviewWhenSending,
|
onTap: toggleShowImagePreviewWhenSending,
|
||||||
trailing: Switch(
|
trailing: Switch.adaptive(
|
||||||
value:
|
value:
|
||||||
userService.currentUser.showShowImagePreviewWhenSending,
|
userService.currentUser.showShowImagePreviewWhenSending,
|
||||||
onChanged: (a) => toggleShowImagePreviewWhenSending(),
|
onChanged: (a) => toggleShowImagePreviewWhenSending(),
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ class _SetupBackupViewState extends State<SetupBackupView> {
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 12,
|
height: 12,
|
||||||
width: 12,
|
width: 12,
|
||||||
child: CircularProgressIndicator(strokeWidth: 1),
|
child: CircularProgressIndicator.adaptive(strokeWidth: 1),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.lock_clock_rounded),
|
: const Icon(Icons.lock_clock_rounded),
|
||||||
label: Text(
|
label: Text(
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class _ChatSettingsViewState extends State<ChatSettingsView> {
|
||||||
title: Text(context.lang.settingsPreSelectedReactions),
|
title: Text(context.lang.settingsPreSelectedReactions),
|
||||||
onTap: () => context.push(Routes.settingsChatsReactions),
|
onTap: () => context.push(Routes.settingsChatsReactions),
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
SwitchListTile.adaptive(
|
||||||
title: Text(
|
title: Text(
|
||||||
context
|
context
|
||||||
.lang
|
.lang
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
@ -39,6 +37,35 @@ class _DataAndStorageViewState extends State<DataAndStorageView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleStoreInGallery() async {
|
Future<void> toggleStoreInGallery() async {
|
||||||
|
final currentlyEnabled = userService.currentUser.storeMediaFilesInGallery;
|
||||||
|
if (currentlyEnabled) {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(context.lang.galleryDisableWarningTitle),
|
||||||
|
content: Text(context.lang.galleryDisableWarningBody),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: Text(context.lang.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: Text(
|
||||||
|
context.lang.galleryDisableWarningConfirm,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (confirm != true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await UserService.update((u) {
|
await UserService.update((u) {
|
||||||
u.storeMediaFilesInGallery = !u.storeMediaFilesInGallery;
|
u.storeMediaFilesInGallery = !u.storeMediaFilesInGallery;
|
||||||
});
|
});
|
||||||
|
|
@ -83,11 +110,8 @@ class _DataAndStorageViewState extends State<DataAndStorageView> {
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(context.lang.settingsStorageDataStoreInGTitle),
|
title: Text(context.lang.settingsStorageDataStoreInGTitle),
|
||||||
subtitle: Text(
|
|
||||||
context.lang.settingsStorageDataStoreInGSubtitle,
|
|
||||||
),
|
|
||||||
onTap: toggleStoreInGallery,
|
onTap: toggleStoreInGallery,
|
||||||
trailing: Switch(
|
trailing: Switch.adaptive(
|
||||||
value: userService.currentUser.storeMediaFilesInGallery,
|
value: userService.currentUser.storeMediaFilesInGallery,
|
||||||
onChanged: (a) => toggleStoreInGallery(),
|
onChanged: (a) => toggleStoreInGallery(),
|
||||||
),
|
),
|
||||||
|
|
@ -99,27 +123,19 @@ class _DataAndStorageViewState extends State<DataAndStorageView> {
|
||||||
style: const TextStyle(fontSize: 9),
|
style: const TextStyle(fontSize: 9),
|
||||||
),
|
),
|
||||||
onTap: toggleAutoStoreMediaFiles,
|
onTap: toggleAutoStoreMediaFiles,
|
||||||
trailing: Switch(
|
trailing: Switch.adaptive(
|
||||||
value: userService
|
value: userService
|
||||||
.currentUser
|
.currentUser
|
||||||
.autoStoreAllSendUnlimitedMediaFiles,
|
.autoStoreAllSendUnlimitedMediaFiles,
|
||||||
onChanged: (a) => toggleAutoStoreMediaFiles(),
|
onChanged: (a) => toggleAutoStoreMediaFiles(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (Platform.isAndroid)
|
ListTile(
|
||||||
ListTile(
|
title: Text(context.lang.settingsStorageScanGalleryTitle),
|
||||||
title: Text(
|
onTap: () {
|
||||||
context.lang.exportMemories,
|
context.push(Routes.settingsStorageImportGallery);
|
||||||
),
|
},
|
||||||
onTap: () => context.push(Routes.settingsStorageExport),
|
),
|
||||||
),
|
|
||||||
if (Platform.isAndroid)
|
|
||||||
ListTile(
|
|
||||||
title: Text(
|
|
||||||
context.lang.importMemories,
|
|
||||||
),
|
|
||||||
onTap: () => context.push(Routes.settingsStorageImport),
|
|
||||||
),
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
|
|
@ -197,7 +213,7 @@ class _AutoDownloadOptionsDialogState extends State<AutoDownloadOptionsDialog> {
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
CheckboxListTile(
|
CheckboxListTile.adaptive(
|
||||||
title: const Text('Image'),
|
title: const Text('Image'),
|
||||||
value: autoDownloadOptions[widget.connectionMode.name]!.contains(
|
value: autoDownloadOptions[widget.connectionMode.name]!.contains(
|
||||||
DownloadMediaTypes.image.name,
|
DownloadMediaTypes.image.name,
|
||||||
|
|
@ -206,7 +222,7 @@ class _AutoDownloadOptionsDialogState extends State<AutoDownloadOptionsDialog> {
|
||||||
await _updateAutoDownloadSetting(DownloadMediaTypes.image, value);
|
await _updateAutoDownloadSetting(DownloadMediaTypes.image, value);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
CheckboxListTile(
|
CheckboxListTile.adaptive(
|
||||||
title: const Text('Video'),
|
title: const Text('Video'),
|
||||||
value: autoDownloadOptions[widget.connectionMode.name]!.contains(
|
value: autoDownloadOptions[widget.connectionMode.name]!.contains(
|
||||||
DownloadMediaTypes.video.name,
|
DownloadMediaTypes.video.name,
|
||||||
|
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:archive/archive_io.dart';
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:twonly/globals.dart';
|
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
|
||||||
import 'package:twonly/src/utils/log.dart';
|
|
||||||
|
|
||||||
class AndroidMediaStore {
|
|
||||||
static const androidMediaStoreChannel = MethodChannel('eu.twonly/mediaStore');
|
|
||||||
|
|
||||||
static Future<bool> safeFileToDownload(File sourceFile) async {
|
|
||||||
try {
|
|
||||||
Log.info('Storing $sourceFile');
|
|
||||||
final storedPath = (
|
|
||||||
await androidMediaStoreChannel.invokeMethod('safeFileToDownload', {
|
|
||||||
'sourceFile': sourceFile.path,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
Log.info(storedPath);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
Log.error(e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExportMediaView extends StatefulWidget {
|
|
||||||
const ExportMediaView({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ExportMediaView> createState() => _ExportMediaViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ExportMediaViewState extends State<ExportMediaView> {
|
|
||||||
double _progress = 0;
|
|
||||||
String? _status;
|
|
||||||
File? _zipFile;
|
|
||||||
bool _isZipping = false;
|
|
||||||
bool _zipSaved = false;
|
|
||||||
bool _isStoring = false;
|
|
||||||
|
|
||||||
Directory _mediaFolder() {
|
|
||||||
final dir = MediaFileService.buildDirectoryPath(
|
|
||||||
'stored',
|
|
||||||
AppEnvironment.supportDir,
|
|
||||||
);
|
|
||||||
if (!dir.existsSync()) dir.createSync(recursive: true);
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _createZipFromMediaFolder() async {
|
|
||||||
setState(() {
|
|
||||||
_isZipping = true;
|
|
||||||
_progress = 0.0;
|
|
||||||
_status = 'Preparing...';
|
|
||||||
_zipFile = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final folder = _mediaFolder();
|
|
||||||
final allFiles = folder
|
|
||||||
.listSync(recursive: true)
|
|
||||||
.whereType<File>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final mediaFiles = allFiles.where((f) {
|
|
||||||
final name = p.basename(f.path).toLowerCase();
|
|
||||||
if (name.contains('thumbnail')) return false;
|
|
||||||
return true;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
if (mediaFiles.isEmpty) {
|
|
||||||
setState(() {
|
|
||||||
_status = 'No memories found.';
|
|
||||||
_isZipping = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// compute total bytes for progress
|
|
||||||
var totalBytes = 0;
|
|
||||||
for (final f in mediaFiles) {
|
|
||||||
totalBytes += await f.length();
|
|
||||||
}
|
|
||||||
|
|
||||||
final tempDir = await getTemporaryDirectory();
|
|
||||||
final zipPath = p.join(
|
|
||||||
tempDir.path,
|
|
||||||
'memories.zip',
|
|
||||||
);
|
|
||||||
final encoder = ZipFileEncoder()..create(zipPath);
|
|
||||||
|
|
||||||
var processedBytes = 0;
|
|
||||||
for (final f in mediaFiles) {
|
|
||||||
final relative = p.relative(f.path, from: folder.path);
|
|
||||||
setState(() {
|
|
||||||
_status = 'Adding $relative';
|
|
||||||
});
|
|
||||||
|
|
||||||
await encoder.addFile(f, relative);
|
|
||||||
|
|
||||||
processedBytes += await f.length();
|
|
||||||
setState(() {
|
|
||||||
_progress = totalBytes > 0 ? processedBytes / totalBytes : 0.0;
|
|
||||||
});
|
|
||||||
|
|
||||||
await Future.delayed(
|
|
||||||
const Duration(milliseconds: 10),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await encoder.close();
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_zipFile = File(zipPath);
|
|
||||||
_status = 'ZIP created: ${p.basename(zipPath)}';
|
|
||||||
_progress = 1.0;
|
|
||||||
_isZipping = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_status = 'Error: $e';
|
|
||||||
_isZipping = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _saveZip() async {
|
|
||||||
if (_zipFile == null) return;
|
|
||||||
setState(() {
|
|
||||||
_isStoring = true;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
if (Platform.isAndroid) {
|
|
||||||
if (!await AndroidMediaStore.safeFileToDownload(_zipFile!)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final outputFile = await FilePicker.platform.saveFile(
|
|
||||||
dialogTitle: 'Save your memories to desired location',
|
|
||||||
fileName: p.basename(_zipFile!.path),
|
|
||||||
bytes: _zipFile!.readAsBytesSync(),
|
|
||||||
);
|
|
||||||
if (outputFile == null) return;
|
|
||||||
}
|
|
||||||
_zipSaved = true;
|
|
||||||
_isStoring = false;
|
|
||||||
_status = 'ZIP stored: ${p.basename(_zipFile!.path)}';
|
|
||||||
setState(() {});
|
|
||||||
} catch (e) {
|
|
||||||
_isStoring = false;
|
|
||||||
setState(() => _status = 'Save failed: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Export memories'),
|
|
||||||
),
|
|
||||||
body: Container(
|
|
||||||
margin: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Here, you can export all you memories.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
if (_isZipping || _zipFile != null)
|
|
||||||
LinearProgressIndicator(
|
|
||||||
value: _isZipping ? _progress : (_zipFile != null ? 1.0 : 0.0),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (_status != null)
|
|
||||||
Text(
|
|
||||||
_status!,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
if (_zipFile == null)
|
|
||||||
ElevatedButton.icon(
|
|
||||||
icon: const Icon(Icons.archive),
|
|
||||||
label: Text(
|
|
||||||
_isZipping ? 'Zipping...' : 'Create ZIP from mediafiles',
|
|
||||||
),
|
|
||||||
onPressed: _isZipping ? null : _createZipFromMediaFolder,
|
|
||||||
)
|
|
||||||
else if (!_zipSaved)
|
|
||||||
ElevatedButton.icon(
|
|
||||||
icon: const Icon(Icons.save_alt),
|
|
||||||
label: const Text('Save ZIP'),
|
|
||||||
onPressed: (_zipFile != null && !_isZipping && !_isStoring)
|
|
||||||
? _saveZip
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,761 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:drift/drift.dart' show Value;
|
||||||
|
import 'package:exif/exif.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
import 'package:twonly/locator.dart';
|
||||||
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
import 'package:twonly/src/services/android_photo_picker.service.dart';
|
||||||
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart' show ShortCutsExtension, sha256File;
|
||||||
|
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||||
|
import 'package:twonly/src/visual/themes/light.dart';
|
||||||
|
|
||||||
|
class ImportFromGalleryView extends StatefulWidget {
|
||||||
|
const ImportFromGalleryView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ImportFromGalleryView> createState() => _ImportFromGalleryViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isImporting = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
bool _hasPermission = false;
|
||||||
|
List<AssetEntity> _assets = [];
|
||||||
|
final Set<String> _selectedAssetIds = {};
|
||||||
|
bool _showingAllImages = false;
|
||||||
|
double _importProgress = 0;
|
||||||
|
String _importStatus = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_launchAndroidPicker();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_checkPermissionAndLoad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _launchAndroidPicker() async {
|
||||||
|
final uris = await AndroidPhotoPickerService.pickImages();
|
||||||
|
if (uris.isEmpty) {
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isImporting = true;
|
||||||
|
_importProgress = 0;
|
||||||
|
_importStatus = context.lang.importGalleryStarting;
|
||||||
|
});
|
||||||
|
|
||||||
|
final total = uris.length;
|
||||||
|
var importedCount = 0;
|
||||||
|
var duplicated = 0;
|
||||||
|
var failedCount = 0;
|
||||||
|
|
||||||
|
for (final uri in uris) {
|
||||||
|
try {
|
||||||
|
setState(() {
|
||||||
|
_importStatus = context.lang.importGalleryImportingOf(
|
||||||
|
importedCount + failedCount + 1,
|
||||||
|
total,
|
||||||
|
);
|
||||||
|
_importProgress = (importedCount + failedCount) / total;
|
||||||
|
});
|
||||||
|
|
||||||
|
final bytes = await AndroidPhotoPickerService.getUriBytes(uri);
|
||||||
|
if (bytes == null) {
|
||||||
|
failedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hash = Uint8List.fromList(sha256.convert(bytes).bytes);
|
||||||
|
|
||||||
|
final exsits = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
|
||||||
|
if (exsits.isNotEmpty) {
|
||||||
|
duplicated += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get time from EXIF bytes, fallback to current time
|
||||||
|
final createdAt =
|
||||||
|
await getCreationTimeFromBytes(bytes) ?? DateTime.now();
|
||||||
|
|
||||||
|
const type = MediaType.image;
|
||||||
|
|
||||||
|
final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
|
||||||
|
MediaFilesCompanion(
|
||||||
|
type: const Value(type),
|
||||||
|
createdAt: Value(createdAt),
|
||||||
|
storedFileHash: Value(hash),
|
||||||
|
stored: const Value(true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mediaFile != null) {
|
||||||
|
final mediaService = MediaFileService(mediaFile);
|
||||||
|
await mediaService.storedPath.parent.create(recursive: true);
|
||||||
|
await File(mediaService.storedPath.path).writeAsBytes(bytes);
|
||||||
|
|
||||||
|
await mediaService.calculateAndSaveSize();
|
||||||
|
await mediaService.createThumbnail();
|
||||||
|
unawaited(mediaService.cropTransparentBorders());
|
||||||
|
|
||||||
|
importedCount++;
|
||||||
|
} else {
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isImporting = false;
|
||||||
|
_importProgress = 1;
|
||||||
|
});
|
||||||
|
showSnackbar(
|
||||||
|
context,
|
||||||
|
context.lang.importGalleryComplete(
|
||||||
|
importedCount,
|
||||||
|
duplicated,
|
||||||
|
failedCount,
|
||||||
|
),
|
||||||
|
level: SnackbarLevel.success,
|
||||||
|
);
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkPermissionAndLoad() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final ps = await PhotoManager.requestPermissionExtend();
|
||||||
|
if (ps.isAuth || ps.hasAccess) {
|
||||||
|
setState(() {
|
||||||
|
_hasPermission = true;
|
||||||
|
});
|
||||||
|
await _loadMedia();
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_hasPermission = false;
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = context.lang.importGalleryPermissionRequired;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = context.lang.importGalleryPermissionError(e.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMedia() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (_showingAllImages) {
|
||||||
|
final albums = await PhotoManager.getAssetPathList(onlyAll: true);
|
||||||
|
if (albums.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_assets = [];
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final recentAlbum = albums.first;
|
||||||
|
final count = await recentAlbum.assetCountAsync;
|
||||||
|
final assets = await recentAlbum.getAssetListRange(
|
||||||
|
start: 0,
|
||||||
|
end: count,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_assets = assets;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
final albums = await PhotoManager.getAssetPathList();
|
||||||
|
|
||||||
|
AssetPathEntity? twonlyAlbum;
|
||||||
|
for (final album in albums) {
|
||||||
|
if (album.name.toLowerCase() == 'twonly') {
|
||||||
|
twonlyAlbum = album;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (twonlyAlbum == null) {
|
||||||
|
setState(() {
|
||||||
|
_assets = [];
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final count = await twonlyAlbum.assetCountAsync;
|
||||||
|
final assets = await twonlyAlbum.getAssetListRange(
|
||||||
|
start: 0,
|
||||||
|
end: count,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_assets = assets;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = context.lang.importGalleryLoadError(e.toString());
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DateTime> getImageCreationTime(AssetEntity asset, File file) async {
|
||||||
|
final dates = <DateTime>[asset.createDateTime];
|
||||||
|
|
||||||
|
if (!file.existsSync()) {
|
||||||
|
return asset.createDateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the EXIF data
|
||||||
|
try {
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
final data = await readExifFromBytes(bytes);
|
||||||
|
|
||||||
|
for (final key in data.keys) {
|
||||||
|
if (key.toLowerCase().contains('datetime') || key.contains('Time')) {
|
||||||
|
final time = data[key]?.printable;
|
||||||
|
if (time != null) {
|
||||||
|
try {
|
||||||
|
dates.add(
|
||||||
|
DateFormat('yyyy:MM:dd HH:mm:ss').parse(time),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore unparseable formats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore EXIF reading errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the oldest available date
|
||||||
|
return dates.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DateTime?> getCreationTimeFromBytes(Uint8List bytes) async {
|
||||||
|
final dates = <DateTime>[];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final data = await readExifFromBytes(bytes);
|
||||||
|
|
||||||
|
for (final key in data.keys) {
|
||||||
|
if (key.toLowerCase().contains('datetime') || key.contains('Time')) {
|
||||||
|
final time = data[key]?.printable;
|
||||||
|
if (time != null) {
|
||||||
|
try {
|
||||||
|
dates.add(
|
||||||
|
DateFormat('yyyy:MM:dd HH:mm:ss').parse(time),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore unparseable formats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore EXIF reading errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dates.isEmpty) return null;
|
||||||
|
|
||||||
|
// Return the oldest available date
|
||||||
|
return dates.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSelectAll() {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedAssetIds.length == _assets.length) {
|
||||||
|
_selectedAssetIds.clear();
|
||||||
|
} else {
|
||||||
|
_selectedAssetIds.clear();
|
||||||
|
for (final asset in _assets) {
|
||||||
|
_selectedAssetIds.add(asset.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startImport() async {
|
||||||
|
if (_selectedAssetIds.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isImporting = true;
|
||||||
|
_importProgress = 0;
|
||||||
|
_importStatus = context.lang.importGalleryStarting;
|
||||||
|
});
|
||||||
|
|
||||||
|
final selectedAssets = _assets
|
||||||
|
.where((a) => _selectedAssetIds.contains(a.id))
|
||||||
|
.toList();
|
||||||
|
final total = selectedAssets.length;
|
||||||
|
var importedCount = 0;
|
||||||
|
var duplicated = 0;
|
||||||
|
var failedCount = 0;
|
||||||
|
|
||||||
|
for (final asset in selectedAssets) {
|
||||||
|
try {
|
||||||
|
setState(() {
|
||||||
|
_importStatus = context.lang.importGalleryImportingOf(
|
||||||
|
importedCount + failedCount + 1,
|
||||||
|
total,
|
||||||
|
);
|
||||||
|
_importProgress = (importedCount + failedCount) / total;
|
||||||
|
});
|
||||||
|
|
||||||
|
final file = await asset.file;
|
||||||
|
if (file == null || !file.existsSync()) {
|
||||||
|
failedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hash = Uint8List.fromList(await sha256File(file));
|
||||||
|
|
||||||
|
final exsits = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
|
||||||
|
if (exsits.isNotEmpty) {
|
||||||
|
duplicated += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final createdAt = await getImageCreationTime(asset, file);
|
||||||
|
|
||||||
|
// Determine media type
|
||||||
|
late final MediaType type;
|
||||||
|
if (asset.type == AssetType.video) {
|
||||||
|
type = MediaType.video;
|
||||||
|
} else if (file.path.toLowerCase().endsWith('.gif')) {
|
||||||
|
type = MediaType.gif;
|
||||||
|
} else {
|
||||||
|
type = MediaType.image;
|
||||||
|
}
|
||||||
|
|
||||||
|
final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
|
||||||
|
MediaFilesCompanion(
|
||||||
|
type: Value(type),
|
||||||
|
createdAt: Value(createdAt),
|
||||||
|
storedFileHash: Value(hash),
|
||||||
|
stored: const Value(true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mediaFile != null) {
|
||||||
|
final mediaService = MediaFileService(mediaFile);
|
||||||
|
await mediaService.storedPath.parent.create(recursive: true);
|
||||||
|
await file.copy(mediaService.storedPath.path);
|
||||||
|
|
||||||
|
await mediaService.calculateAndSaveSize();
|
||||||
|
await mediaService.createThumbnail();
|
||||||
|
unawaited(mediaService.cropTransparentBorders());
|
||||||
|
|
||||||
|
importedCount++;
|
||||||
|
} else {
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isImporting = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
showSnackbar(
|
||||||
|
context,
|
||||||
|
context.lang.importGalleryComplete(
|
||||||
|
importedCount,
|
||||||
|
duplicated,
|
||||||
|
failedCount,
|
||||||
|
),
|
||||||
|
level: SnackbarLevel.success,
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isAllSelected =
|
||||||
|
_assets.isNotEmpty && _selectedAssetIds.length == _assets.length;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
extendBody: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(context.lang.settingsStorageScanGalleryTitle),
|
||||||
|
actions: [
|
||||||
|
if (!_isLoading && _assets.isNotEmpty && !_isImporting)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
isAllSelected ? Icons.deselect : Icons.select_all,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
tooltip: isAllSelected
|
||||||
|
? context.lang.importGalleryDeselectAll
|
||||||
|
: context.lang.importGallerySelectAll,
|
||||||
|
onPressed: _toggleSelectAll,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (!_isLoading && !_isImporting && _hasPermission)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).dividerColor.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
context.lang.importGalleryFilterTwonly,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch.adaptive(
|
||||||
|
value: !_showingAllImages,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_showingAllImages = !value;
|
||||||
|
_selectedAssetIds.clear();
|
||||||
|
});
|
||||||
|
_loadMedia();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: _buildBody()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_isImporting) _buildImportingOverlay(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottomNavigationBar:
|
||||||
|
_assets.isEmpty ||
|
||||||
|
_isLoading ||
|
||||||
|
_isImporting ||
|
||||||
|
_selectedAssetIds.isEmpty
|
||||||
|
? null
|
||||||
|
: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: primaryColorButtonStyle,
|
||||||
|
onPressed: _startImport,
|
||||||
|
child: Text(
|
||||||
|
_selectedAssetIds.isEmpty
|
||||||
|
? context.lang.importGallerySelectToImport
|
||||||
|
: context.lang.importGalleryImportCount(
|
||||||
|
_selectedAssetIds.length,
|
||||||
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator.adaptive());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_hasPermission) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.photo_library_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_errorMessage ?? context.lang.importGalleryPermissionDenied,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _checkPermissionAndLoad,
|
||||||
|
child: Text(context.lang.importGalleryGrantAccess),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: PhotoManager.openSetting,
|
||||||
|
child: Text(context.lang.importGalleryOpenSettings),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_errorMessage != null) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 64, color: Colors.red),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_errorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _checkPermissionAndLoad,
|
||||||
|
child: Text(context.lang.importGalleryTryAgain),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_assets.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.photo_album_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_showingAllImages
|
||||||
|
? context.lang.importGalleryNoImagesFound
|
||||||
|
: context.lang.importGalleryAlbumNotFound,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_showingAllImages
|
||||||
|
? context.lang.importGalleryNoImagesFoundDesc
|
||||||
|
: context.lang.importGalleryAlbumNotFoundDesc,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _checkPermissionAndLoad,
|
||||||
|
child: Text(context.lang.importGalleryRefresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GridView.builder(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
MediaQuery.of(context).padding.bottom + 80,
|
||||||
|
),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 4,
|
||||||
|
mainAxisSpacing: 2,
|
||||||
|
crossAxisSpacing: 2,
|
||||||
|
childAspectRatio: 9 / 16,
|
||||||
|
),
|
||||||
|
itemCount: _assets.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final asset = _assets[index];
|
||||||
|
final isSelected = _selectedAssetIds.contains(asset.id);
|
||||||
|
return GalleryThumbnailWidget(
|
||||||
|
asset: asset,
|
||||||
|
isSelected: isSelected,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
if (isSelected) {
|
||||||
|
_selectedAssetIds.remove(asset.id);
|
||||||
|
} else {
|
||||||
|
_selectedAssetIds.add(asset.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImportingOverlay() {
|
||||||
|
return ColoredBox(
|
||||||
|
color: Colors.black54,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator.adaptive(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
_importStatus,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
LinearProgressIndicator(value: _importProgress),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GalleryThumbnailWidget extends StatefulWidget {
|
||||||
|
const GalleryThumbnailWidget({
|
||||||
|
required this.asset,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AssetEntity asset;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GalleryThumbnailWidget> createState() => _GalleryThumbnailWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GalleryThumbnailWidgetState extends State<GalleryThumbnailWidget> {
|
||||||
|
late final Future<Uint8List?> _thumbnailFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_thumbnailFuture = widget.asset.thumbnailData;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: widget.onTap,
|
||||||
|
child: SelectableThumbnailComp(
|
||||||
|
isSelected: widget.isSelected,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
FutureBuilder<Uint8List?>(
|
||||||
|
future: _thumbnailFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.done &&
|
||||||
|
snapshot.data != null) {
|
||||||
|
return Image.memory(
|
||||||
|
snapshot.data!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ColoredBox(
|
||||||
|
color: Colors.grey.withValues(alpha: 0.1),
|
||||||
|
child: const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (widget.asset.type == AssetType.video)
|
||||||
|
const Positioned.fill(
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.play_circle_outline,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 32,
|
||||||
|
shadows: [
|
||||||
|
Shadow(color: Colors.black54, blurRadius: 6),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:archive/archive_io.dart';
|
|
||||||
import 'package:drift/drift.dart' show Value;
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
|
||||||
import 'package:twonly/locator.dart';
|
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
|
||||||
|
|
||||||
class ImportMediaView extends StatefulWidget {
|
|
||||||
const ImportMediaView({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ImportMediaView> createState() => _ImportMediaViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ImportMediaViewState extends State<ImportMediaView> {
|
|
||||||
double _progress = 0;
|
|
||||||
String? _status;
|
|
||||||
File? _zipFile;
|
|
||||||
bool _isProcessing = false;
|
|
||||||
|
|
||||||
Future<void> _pickAndImportZip() async {
|
|
||||||
setState(() {
|
|
||||||
_status = null;
|
|
||||||
_progress = 0;
|
|
||||||
_zipFile = null;
|
|
||||||
_isProcessing = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final result = await FilePicker.platform.pickFiles(
|
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: ['zip'],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result == null || result.files.isEmpty) {
|
|
||||||
setState(() {
|
|
||||||
_status = 'No file selected.';
|
|
||||||
_isProcessing = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final pickedPath = result.files.single.path;
|
|
||||||
if (pickedPath == null) {
|
|
||||||
setState(() {
|
|
||||||
_status = 'Selected file has no path.';
|
|
||||||
_isProcessing = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final pickedFile = File(pickedPath);
|
|
||||||
if (!pickedFile.existsSync()) {
|
|
||||||
setState(() {
|
|
||||||
_status = 'Selected file does not exist.';
|
|
||||||
_isProcessing = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_zipFile = pickedFile;
|
|
||||||
_status = 'Selected ${p.basename(pickedPath)}';
|
|
||||||
});
|
|
||||||
|
|
||||||
await _extractZipToMediaFolder(pickedFile);
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_status = 'Error: $e';
|
|
||||||
_isProcessing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _extractZipToMediaFolder(File zipFile) async {
|
|
||||||
setState(() {
|
|
||||||
_status = 'Reading archive...';
|
|
||||||
_progress = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final stream = InputFileStream(zipFile.path);
|
|
||||||
|
|
||||||
final archive = ZipDecoder().decodeStream(stream);
|
|
||||||
|
|
||||||
// Optionally: compute total entries to show progress
|
|
||||||
final entries = archive.where((e) => e.isFile).toList();
|
|
||||||
final total = entries.length;
|
|
||||||
var processed = 0;
|
|
||||||
|
|
||||||
for (final file in entries) {
|
|
||||||
if (!file.isFile || file.isSymbolicLink) continue;
|
|
||||||
|
|
||||||
final extSplit = file.name.split('.');
|
|
||||||
if (extSplit.isEmpty) continue;
|
|
||||||
final ext = extSplit.last;
|
|
||||||
|
|
||||||
late MediaType type;
|
|
||||||
switch (ext) {
|
|
||||||
case 'webp':
|
|
||||||
type = MediaType.image;
|
|
||||||
case 'mp4':
|
|
||||||
type = MediaType.video;
|
|
||||||
case 'gif':
|
|
||||||
type = MediaType.gif;
|
|
||||||
default:
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
|
|
||||||
MediaFilesCompanion(
|
|
||||||
type: Value(type),
|
|
||||||
createdAt: Value(file.lastModDateTime),
|
|
||||||
stored: const Value(true),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final mediaService = MediaFileService(mediaFile!);
|
|
||||||
await mediaService.storedPath.writeAsBytes(file.content);
|
|
||||||
|
|
||||||
processed++;
|
|
||||||
setState(() {
|
|
||||||
_progress = total > 0 ? processed / total : 0;
|
|
||||||
_status = 'Imported ${file.name}';
|
|
||||||
});
|
|
||||||
|
|
||||||
// allow UI to update for large archives
|
|
||||||
await Future.delayed(const Duration(milliseconds: 10));
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_status = 'Import complete. ${entries.length} entries processed.';
|
|
||||||
_isProcessing = false;
|
|
||||||
_progress = 1;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_status = 'Extraction failed: $e';
|
|
||||||
_isProcessing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Import memories'),
|
|
||||||
),
|
|
||||||
body: Container(
|
|
||||||
margin: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Here, you can import exported memories.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
if (_isProcessing || _zipFile != null)
|
|
||||||
LinearProgressIndicator(
|
|
||||||
value: _isProcessing
|
|
||||||
? _progress
|
|
||||||
: (_zipFile != null ? 1.0 : 0.0),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (_status != null)
|
|
||||||
Text(
|
|
||||||
_status!,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
icon: const Icon(Icons.file_upload),
|
|
||||||
label: Text(
|
|
||||||
_isProcessing
|
|
||||||
? 'Processing...'
|
|
||||||
: 'Select memories.zip to import',
|
|
||||||
),
|
|
||||||
onPressed: _isProcessing ? null : _pickAndImportZip,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
|
|
@ -9,12 +10,15 @@ import 'package:flutter/services.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:restart_app/restart_app.dart';
|
import 'package:restart_app/restart_app.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:twonly/globals.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/constants/routes.keys.dart';
|
import 'package:twonly/src/constants/routes.keys.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
import 'package:twonly/src/services/user.service.dart';
|
import 'package:twonly/src/services/user.service.dart';
|
||||||
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/utils/storage.dart';
|
import 'package:twonly/src/utils/storage.dart';
|
||||||
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
||||||
|
|
@ -259,6 +263,12 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> toggleDatabaseLogging() async {
|
||||||
|
await UserService.update(
|
||||||
|
(u) => u.enableDatabaseLogging = !u.enableDatabaseLogging,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -273,11 +283,19 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('Show Developer Settings'),
|
title: const Text('Show Developer Settings'),
|
||||||
onTap: toggleDeveloperSettings,
|
onTap: toggleDeveloperSettings,
|
||||||
trailing: Switch(
|
trailing: Switch.adaptive(
|
||||||
value: userService.currentUser.isDeveloper,
|
value: userService.currentUser.isDeveloper,
|
||||||
onChanged: (_) => toggleDeveloperSettings(),
|
onChanged: (_) => toggleDeveloperSettings(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Enable Database Logging'),
|
||||||
|
onTap: toggleDatabaseLogging,
|
||||||
|
trailing: Switch.adaptive(
|
||||||
|
value: userService.currentUser.enableDatabaseLogging,
|
||||||
|
onChanged: (_) => toggleDatabaseLogging(),
|
||||||
|
),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('User ID'),
|
title: const Text('User ID'),
|
||||||
subtitle: Text(userService.currentUser.userId.toString()),
|
subtitle: Text(userService.currentUser.userId.toString()),
|
||||||
|
|
@ -293,10 +311,34 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
context.navPush(const UserDiscoveryDeveloperView()),
|
context.navPush(const UserDiscoveryDeveloperView()),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Share local database'),
|
||||||
|
onTap: () async {
|
||||||
|
final dbCopyPath =
|
||||||
|
'${AppEnvironment.cacheDir}/twonly_copy.sqlite';
|
||||||
|
final dbCopyFile = File(dbCopyPath);
|
||||||
|
if (dbCopyFile.existsSync()) {
|
||||||
|
dbCopyFile.deleteSync();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await twonlyDB.customStatement("VACUUM INTO '$dbCopyPath'");
|
||||||
|
if (dbCopyFile.existsSync()) {
|
||||||
|
await SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
files: [XFile(dbCopyPath)],
|
||||||
|
text: 'twonly Database',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Failed to create database copy: $e');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('Toggle Video Stabilization'),
|
title: const Text('Toggle Video Stabilization'),
|
||||||
onTap: toggleVideoStabilization,
|
onTap: toggleVideoStabilization,
|
||||||
trailing: Switch(
|
trailing: Switch.adaptive(
|
||||||
value: userService.currentUser.videoStabilizationEnabled,
|
value: userService.currentUser.videoStabilizationEnabled,
|
||||||
onChanged: (a) => toggleVideoStabilization(),
|
onChanged: (a) => toggleVideoStabilization(),
|
||||||
),
|
),
|
||||||
|
|
@ -358,7 +400,7 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: _isGeneratingMockImages
|
onTap: _isGeneratingMockImages
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ class _ChangeLogViewState extends State<ChangeLogView> {
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(context.lang.openChangeLog),
|
Text(context.lang.openChangeLog),
|
||||||
Switch(
|
Switch.adaptive(
|
||||||
value: !userService.currentUser.hideChangeLog,
|
value: !userService.currentUser.hideChangeLog,
|
||||||
onChanged: (_) => UserService.update(
|
onChanged: (_) => UserService.update(
|
||||||
(u) => u.hideChangeLog = !u.hideChangeLog,
|
(u) => u.hideChangeLog = !u.hideChangeLog,
|
||||||
|
|
|
||||||
|
|
@ -243,9 +243,9 @@ $debugLogToken
|
||||||
? SizedBox(
|
? SizedBox(
|
||||||
height: 12,
|
height: 12,
|
||||||
width: 12,
|
width: 12,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: Theme.of(context).colorScheme.inversePrimary,
|
valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const FaIcon(FontAwesomeIcons.angleRight),
|
: const FaIcon(FontAwesomeIcons.angleRight),
|
||||||
|
|
@ -291,7 +291,7 @@ class _IncludeDebugLogState extends State<IncludeDebugLog> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Checkbox(
|
Checkbox.adaptive(
|
||||||
value: widget.isChecked,
|
value: widget.isChecked,
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ class _FaqViewState extends State<FaqView> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(context.lang.settingsHelpFAQ),
|
title: Text(context.lang.settingsHelpFAQ),
|
||||||
),
|
),
|
||||||
body: const Center(child: CircularProgressIndicator()),
|
body: const Center(child: CircularProgressIndicator.adaptive()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ class _HelpViewState extends State<HelpView> {
|
||||||
style: const TextStyle(fontSize: 10),
|
style: const TextStyle(fontSize: 10),
|
||||||
),
|
),
|
||||||
onTap: toggleAllowErrorTrackingViaSentry,
|
onTap: toggleAllowErrorTrackingViaSentry,
|
||||||
trailing: Switch(
|
trailing: Switch.adaptive(
|
||||||
value: userService.currentUser.allowErrorTrackingViaSentry,
|
value: userService.currentUser.allowErrorTrackingViaSentry,
|
||||||
onChanged: (a) => toggleAllowErrorTrackingViaSentry(),
|
onChanged: (a) => toggleAllowErrorTrackingViaSentry(),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ class _NotificationViewState extends State<NotificationView> {
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -138,7 +138,7 @@ class _NotificationViewState extends State<NotificationView> {
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ class _PrivacyViewState extends State<PrivacyView> {
|
||||||
title: Text(context.lang.settingsTypingIndication),
|
title: Text(context.lang.settingsTypingIndication),
|
||||||
subtitle: Text(context.lang.settingsTypingIndicationSubtitle),
|
subtitle: Text(context.lang.settingsTypingIndicationSubtitle),
|
||||||
onTap: toggleTypingIndicators,
|
onTap: toggleTypingIndicators,
|
||||||
trailing: Switch(
|
trailing: Switch.adaptive(
|
||||||
value: userService.currentUser.typingIndicators,
|
value: userService.currentUser.typingIndicators,
|
||||||
onChanged: (a) => toggleTypingIndicators(),
|
onChanged: (a) => toggleTypingIndicators(),
|
||||||
),
|
),
|
||||||
|
|
@ -106,7 +106,7 @@ class _PrivacyViewState extends State<PrivacyView> {
|
||||||
title: Text(context.lang.settingsScreenLock),
|
title: Text(context.lang.settingsScreenLock),
|
||||||
subtitle: Text(context.lang.settingsScreenLockSubtitle),
|
subtitle: Text(context.lang.settingsScreenLockSubtitle),
|
||||||
onTap: toggleAuthRequirementOnStartup,
|
onTap: toggleAuthRequirementOnStartup,
|
||||||
trailing: Switch(
|
trailing: Switch.adaptive(
|
||||||
value: userService.currentUser.screenLockEnabled,
|
value: userService.currentUser.screenLockEnabled,
|
||||||
onChanged: (a) => toggleAuthRequirementOnStartup(),
|
onChanged: (a) => toggleAuthRequirementOnStartup(),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ class UserList extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
leading: AvatarIcon(contactId: user.userId, fontSize: 15),
|
leading: AvatarIcon(contactId: user.userId, fontSize: 15),
|
||||||
trailing: Checkbox(
|
trailing: Checkbox.adaptive(
|
||||||
value: user.blocked,
|
value: user.blocked,
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
await block(context, user.userId, value);
|
await block(context, user.userId, value);
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@ class UserDiscoverySetupComp extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile(
|
SwitchListTile.adaptive(
|
||||||
value: state.isUserDiscoveryEnabled,
|
value: state.isUserDiscoveryEnabled,
|
||||||
onChanged: (val) => state.update(() {
|
onChanged: (val) => state.update(() {
|
||||||
state.isUserDiscoveryEnabled = val;
|
state.isUserDiscoveryEnabled = val;
|
||||||
|
|
@ -323,7 +323,7 @@ class UserDiscoverySetupComp extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
SwitchListTile.adaptive(
|
||||||
value: state.isManualApprovalEnabled,
|
value: state.isManualApprovalEnabled,
|
||||||
onChanged: (val) => state.update(
|
onChanged: (val) => state.update(
|
||||||
() => state.isManualApprovalEnabled = val,
|
() => state.isManualApprovalEnabled = val,
|
||||||
|
|
@ -547,7 +547,7 @@ class UserDiscoverySetupComp extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile(
|
SwitchListTile.adaptive(
|
||||||
value: state.sharePromotion,
|
value: state.sharePromotion,
|
||||||
onChanged: (val) => state.update(() {
|
onChanged: (val) => state.update(() {
|
||||||
state.sharePromotion = val;
|
state.sharePromotion = val;
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ class _SelectAdditionalUsers extends State<SelectAdditionalUsers> {
|
||||||
contactId: user.userId,
|
contactId: user.userId,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
trailing: Checkbox(
|
trailing: Checkbox.adaptive(
|
||||||
value:
|
value:
|
||||||
selectedUsers.contains(user.userId) |
|
selectedUsers.contains(user.userId) |
|
||||||
_alreadySelected.contains(user.userId),
|
_alreadySelected.contains(user.userId),
|
||||||
|
|
|
||||||
|
|
@ -330,7 +330,7 @@ class _PlanCardState extends State<PlanCard> {
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
child: CircularProgressIndicator(strokeWidth: 1),
|
child: CircularProgressIndicator.adaptive(strokeWidth: 1),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
label: Text(
|
label: Text(
|
||||||
|
|
@ -350,7 +350,7 @@ class _PlanCardState extends State<PlanCard> {
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
child: CircularProgressIndicator(strokeWidth: 1),
|
child: CircularProgressIndicator.adaptive(strokeWidth: 1),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
label: Text(
|
label: Text(
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
|
||||||
contactId: user.userId,
|
contactId: user.userId,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
trailing: Checkbox(
|
trailing: Checkbox.adaptive(
|
||||||
value:
|
value:
|
||||||
selectedUsers.contains(user.userId) |
|
selectedUsers.contains(user.userId) |
|
||||||
_alreadySelected.contains(user.userId),
|
_alreadySelected.contains(user.userId),
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ class _UserStudyQuestionnaireViewState
|
||||||
'Welche der folgenden Messenger hast du schon einmal benutzt?',
|
'Welche der folgenden Messenger hast du schon einmal benutzt?',
|
||||||
),
|
),
|
||||||
..._messengerOptions.map(
|
..._messengerOptions.map(
|
||||||
(m) => CheckboxListTile(
|
(m) => CheckboxListTile.adaptive(
|
||||||
title: Text(m),
|
title: Text(m),
|
||||||
visualDensity: const VisualDensity(vertical: -4),
|
visualDensity: const VisualDensity(vertical: -4),
|
||||||
value: (_responses['messengers'] as List<dynamic>).contains(m),
|
value: (_responses['messengers'] as List<dynamic>).contains(m),
|
||||||
|
|
|
||||||
22
pubspec.lock
22
pubspec.lock
|
|
@ -448,6 +448,13 @@ packages:
|
||||||
url: "https://github.com/otsmr/emoji_picker_flutter.git"
|
url: "https://github.com/otsmr/emoji_picker_flutter.git"
|
||||||
source: git
|
source: git
|
||||||
version: "4.4.0"
|
version: "4.4.0"
|
||||||
|
exif:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "dependencies/exif"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "3.3.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1506,6 +1513,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.2"
|
version: "7.0.2"
|
||||||
|
photo_manager:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: photo_manager
|
||||||
|
sha256: fb3bc8ea653370f88742b3baa304700107c83d12748aa58b2b9f2ed3ef15e6c2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.9.0"
|
||||||
photo_view:
|
photo_view:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1792,6 +1807,13 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
version: "1.10.2"
|
||||||
|
sprintf:
|
||||||
|
dependency: "direct overridden"
|
||||||
|
description:
|
||||||
|
path: "dependencies/sprintf"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "7.0.0"
|
||||||
sqflite:
|
sqflite:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 0.2.20+129
|
version: 0.2.26+135
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0
|
sdk: ^3.11.0
|
||||||
|
|
@ -86,6 +86,7 @@ dependencies:
|
||||||
flutter_sharing_intent: ^2.0.4
|
flutter_sharing_intent: ^2.0.4
|
||||||
screen_protector: ^1.5.1
|
screen_protector: ^1.5.1
|
||||||
flutter_markdown_plus: ^1.0.7
|
flutter_markdown_plus: ^1.0.7
|
||||||
|
exif: ^3.3.0
|
||||||
|
|
||||||
# With high download. (But should be checked nonetheless.)
|
# With high download. (But should be checked nonetheless.)
|
||||||
app_links: ^7.0.0 # 1.6 mio
|
app_links: ^7.0.0 # 1.6 mio
|
||||||
|
|
@ -107,6 +108,7 @@ dependencies:
|
||||||
flutter_image_compress: ^2.4.0
|
flutter_image_compress: ^2.4.0
|
||||||
flutter_volume_controller: ^1.3.4
|
flutter_volume_controller: ^1.3.4
|
||||||
gal: ^2.3.1
|
gal: ^2.3.1
|
||||||
|
photo_manager: ^3.9.0
|
||||||
google_mlkit_barcode_scanning: ^0.14.1
|
google_mlkit_barcode_scanning: ^0.14.1
|
||||||
google_mlkit_face_detection: ^0.13.1
|
google_mlkit_face_detection: ^0.13.1
|
||||||
pro_video_editor: ^1.6.1
|
pro_video_editor: ^1.6.1
|
||||||
|
|
@ -172,6 +174,10 @@ dependency_overrides:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/yenchieh/flutter_android_volume_keydown.git
|
url: https://github.com/yenchieh/flutter_android_volume_keydown.git
|
||||||
ref: fix/lStar-not-found-error
|
ref: fix/lStar-not-found-error
|
||||||
|
exif:
|
||||||
|
path: ./dependencies/exif
|
||||||
|
sprintf:
|
||||||
|
path: ./dependencies/sprintf
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: ^2.4.15
|
build_runner: ^2.4.15
|
||||||
|
|
|
||||||
|
|
@ -55,15 +55,29 @@ impl BackupArchive {
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_db {
|
if is_db {
|
||||||
|
// To avoid write-lock conflicts with Dart (which has the live database open in write mode),
|
||||||
|
// we copy the database file first, then open the copy in write mode to perform the backup.
|
||||||
|
let temp_copy_path = backup_data_dir.join(format!("{}.temp_copy", file_name));
|
||||||
|
std::fs::copy(&file_path, &temp_copy_path)?;
|
||||||
|
|
||||||
let db = Database::new(
|
let db = Database::new(
|
||||||
&file_path.display().to_string(),
|
&temp_copy_path.display().to_string(),
|
||||||
encryption_key.as_deref(),
|
encryption_key.as_deref(),
|
||||||
false,
|
false, // Open the copy in write mode required for encrypted backups
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let backup_database_file = backup_data_dir.join(file_name).display().to_string();
|
let backup_database_file = backup_data_dir.join(file_name).display().to_string();
|
||||||
db.create_backup(backup_database_file.as_str(), encryption_key.as_deref())
|
db.create_backup(backup_database_file.as_str(), encryption_key.as_deref())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Close database connection to release file lock before removing it
|
||||||
|
drop(db);
|
||||||
|
remove_file(&temp_copy_path)?;
|
||||||
|
|
||||||
|
// Perform integrity check of the new database file
|
||||||
|
let backup_db =
|
||||||
|
Database::new(&backup_database_file, encryption_key.as_deref(), false).await?;
|
||||||
|
backup_db.check_integrity().await?;
|
||||||
} else {
|
} else {
|
||||||
let file_backup = backup_data_dir.join(file_name);
|
let file_backup = backup_data_dir.join(file_name);
|
||||||
std::fs::copy(file_path, file_backup)?;
|
std::fs::copy(file_path, file_backup)?;
|
||||||
|
|
|
||||||
|
|
@ -63,14 +63,14 @@ impl Context {
|
||||||
key_manager.store_to_keychain(&secure_storage)?;
|
key_manager.store_to_keychain(&secure_storage)?;
|
||||||
|
|
||||||
let rust_db_path = database_dir.join("rust_db.sqlite");
|
let rust_db_path = database_dir.join("rust_db.sqlite");
|
||||||
let rust_db = Arc::new(
|
let rust_db = Database::new(
|
||||||
Database::new(
|
&rust_db_path.display().to_string(),
|
||||||
&rust_db_path.display().to_string(),
|
Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)),
|
||||||
Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)),
|
false,
|
||||||
false,
|
)
|
||||||
)
|
.await?;
|
||||||
.await?,
|
rust_db.run_migrations().await?;
|
||||||
);
|
let rust_db = Arc::new(rust_db);
|
||||||
|
|
||||||
Ok(Context::from_standalone(TwonlyStandalone {
|
Ok(Context::from_standalone(TwonlyStandalone {
|
||||||
config,
|
config,
|
||||||
|
|
@ -120,14 +120,14 @@ impl Context {
|
||||||
|
|
||||||
let mut rust_db_key = key_manager.main_key.get_database_key(DatabaseKey::RustDb);
|
let mut rust_db_key = key_manager.main_key.get_database_key(DatabaseKey::RustDb);
|
||||||
|
|
||||||
let rust_db = Arc::new(
|
let rust_db = Database::new(
|
||||||
Database::new(
|
&rust_db_path.display().to_string(),
|
||||||
&rust_db_path.display().to_string(),
|
Some(rust_db_key.as_str()),
|
||||||
Some(rust_db_key.as_str()),
|
false,
|
||||||
false,
|
)
|
||||||
)
|
.await?;
|
||||||
.await?,
|
rust_db.run_migrations().await?;
|
||||||
);
|
let rust_db = Arc::new(rust_db);
|
||||||
|
|
||||||
rust_db_key.zeroize();
|
rust_db_key.zeroize();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,11 @@ impl Database {
|
||||||
let mut connect_options = format!("{db_url}?mode=rwc")
|
let mut connect_options = format!("{db_url}?mode=rwc")
|
||||||
.parse::<SqliteConnectOptions>()?
|
.parse::<SqliteConnectOptions>()?
|
||||||
.log_statements(log_statements_level)
|
.log_statements(log_statements_level)
|
||||||
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
|
.journal_mode(sqlx::sqlite::SqliteJournalMode::Delete)
|
||||||
.foreign_keys(true)
|
.foreign_keys(true)
|
||||||
.read_only(read_only)
|
.read_only(read_only)
|
||||||
.busy_timeout(Duration::from_millis(5000))
|
.busy_timeout(Duration::from_millis(5000))
|
||||||
|
.pragma("synchronous", "FULL")
|
||||||
.pragma("recursive_triggers", "ON")
|
.pragma("recursive_triggers", "ON")
|
||||||
.log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500));
|
.log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500));
|
||||||
|
|
||||||
|
|
@ -43,15 +44,18 @@ impl Database {
|
||||||
.connect_with(connect_options)
|
.connect_with(connect_options)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn run_migrations(&self) -> Result<()> {
|
||||||
sqlx::migrate!("./src/database/migrations")
|
sqlx::migrate!("./src/database/migrations")
|
||||||
.run(&pool)
|
.run(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("migration error: {:?}", e);
|
tracing::error!("migration error: {:?}", e);
|
||||||
TwonlyError::Generic(format!("Migration error: {}", e))
|
TwonlyError::Generic(format!("Migration error: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
Ok(())
|
||||||
Ok(Self { pool })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn create_backup(
|
pub(crate) async fn create_backup(
|
||||||
|
|
@ -91,6 +95,22 @@ impl Database {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn check_integrity(&self) -> Result<()> {
|
||||||
|
let row: (String,) = sqlx::query_as("PRAGMA integrity_check")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| TwonlyError::Generic(format!("Integrity check query failed: {}", e)))?;
|
||||||
|
|
||||||
|
if row.0.to_lowercase() == "ok" {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(TwonlyError::Generic(format!(
|
||||||
|
"Database integrity check failed: {}",
|
||||||
|
row.0
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -109,6 +129,7 @@ mod tests {
|
||||||
|
|
||||||
// 1. Create and initialize database with key
|
// 1. Create and initialize database with key
|
||||||
let db = Database::new(&db_path, Some(key), false).await.unwrap();
|
let db = Database::new(&db_path, Some(key), false).await.unwrap();
|
||||||
|
db.run_migrations().await.unwrap();
|
||||||
ReceivedMessage::insert(&db.pool, 1, b"hello world")
|
ReceivedMessage::insert(&db.pool, 1, b"hello world")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -137,6 +158,7 @@ mod tests {
|
||||||
let key = "secure_password";
|
let key = "secure_password";
|
||||||
|
|
||||||
let db = Database::new(&db_path, Some(key), false).await.unwrap();
|
let db = Database::new(&db_path, Some(key), false).await.unwrap();
|
||||||
|
db.run_migrations().await.unwrap();
|
||||||
ReceivedMessage::insert(&db.pool, 1, b"hello world")
|
ReceivedMessage::insert(&db.pool, 1, b"hello world")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -165,6 +187,7 @@ mod tests {
|
||||||
let backup_path = dir.path().join("backup_plain.sqlite").display().to_string();
|
let backup_path = dir.path().join("backup_plain.sqlite").display().to_string();
|
||||||
|
|
||||||
let db = Database::new(&db_path, None, false).await.unwrap();
|
let db = Database::new(&db_path, None, false).await.unwrap();
|
||||||
|
db.run_migrations().await.unwrap();
|
||||||
ReceivedMessage::insert(&db.pool, 1, b"hello world")
|
ReceivedMessage::insert(&db.pool, 1, b"hello world")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ macro_rules! generate_table_tests {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let db_path = dir.path().join("test.sqlite").display().to_string();
|
let db_path = dir.path().join("test.sqlite").display().to_string();
|
||||||
let db = Database::new(&db_path, None, false).await.unwrap();
|
let db = Database::new(&db_path, None, false).await.unwrap();
|
||||||
|
db.run_migrations().await.unwrap();
|
||||||
|
|
||||||
let _id = $struct::$insert_fn(&db.pool, $($arg),+).await.unwrap();
|
let _id = $struct::$insert_fn(&db.pool, $($arg),+).await.unwrap();
|
||||||
let all = $struct::$select_all_fn(&db.pool).await.unwrap();
|
let all = $struct::$select_all_fn(&db.pool).await.unwrap();
|
||||||
|
|
@ -92,6 +93,7 @@ macro_rules! generate_test_select {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let db_path = dir.path().join("test.sqlite").display().to_string();
|
let db_path = dir.path().join("test.sqlite").display().to_string();
|
||||||
let db = Database::new(&db_path, None, false).await.unwrap();
|
let db = Database::new(&db_path, None, false).await.unwrap();
|
||||||
|
db.run_migrations().await.unwrap();
|
||||||
|
|
||||||
$struct::$insert_fn(&db.pool, $($arg),+).await.unwrap();
|
$struct::$insert_fn(&db.pool, $($arg),+).await.unwrap();
|
||||||
let results = $struct::$select_fn(&db.pool, $($sel_arg),+).await.unwrap();
|
let results = $struct::$select_fn(&db.pool, $($sel_arg),+).await.unwrap();
|
||||||
|
|
|
||||||
BIN
test.jpg
Normal file
BIN
test.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 620 B |
Loading…
Reference in a new issue