Merge pull request #418 from twonlyapp/dev

- Fix: Changed minimum threshold for the user discovery to 3
- Fix: Multiple UI issues
This commit is contained in:
Tobi 2026-06-07 12:58:16 +02:00 committed by GitHub
commit b192945328
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 3711 additions and 2031 deletions

View file

@ -1,5 +1,15 @@
# Changelog # Changelog
## 0.2.30
- Fix: Changed minimum threshold for the user discovery to 3
## 0.2.28
- Improved: Design of some UI components
- Improved: Memories viewer shows state for batch operations and has improved performance
- Fix: Auto-detect if FCM token does not work and trigger a reset
## 0.2.26 ## 0.2.26
- New: Import images from the gallery - New: Import images from the gallery

View file

@ -19,6 +19,7 @@ analyzer:
- "lib/core/**" - "lib/core/**"
- "lib/src/localization/**" - "lib/src/localization/**"
- "rust_builder/" - "rust_builder/"
- "build/"
- "dependencies/**" - "dependencies/**"
- "pubspec.yaml" - "pubspec.yaml"
- "**.arb" - "**.arb"

View file

@ -3,7 +3,6 @@ plugins {
// START: FlutterFire Configuration // START: FlutterFire Configuration
id 'com.google.gms.google-services' id 'com.google.gms.google-services'
// END: FlutterFire Configuration // END: FlutterFire Configuration
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin" id "dev.flutter.flutter-gradle-plugin"
} }

View file

@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip

View file

@ -18,11 +18,11 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.9.1' apply false id "com.android.application" version '8.11.1' apply false
// START: FlutterFire Configuration // START: FlutterFire Configuration
id "com.google.gms.google-services" version "4.3.15" apply false id "com.google.gms.google-services" version "4.3.15" apply false
// END: FlutterFire Configuration // END: FlutterFire Configuration
id "org.jetbrains.kotlin.android" version "2.1.0" apply false id "org.jetbrains.kotlin.android" version "2.2.20" apply false
} }
include ":app" include ":app"

View file

@ -15,6 +15,11 @@ class NotificationService: UNNotificationServiceExtension {
self.contentHandler = contentHandler self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
// Store the current timestamp in Keychain for iOS FCM messaging tracking
let nowMs = String(format: "%.0f", Date().timeIntervalSince1970 * 1000)
writeToKeychain(key: "last_fcm_message_timestamp", value: nowMs)
NSLog("Received APNs push notification, updated last_fcm_message_timestamp to \(nowMs)")
if let bestAttemptContent = bestAttemptContent { if let bestAttemptContent = bestAttemptContent {
guard bestAttemptContent.userInfo as? [String: Any] != nil, guard bestAttemptContent.userInfo as? [String: Any] != nil,
@ -188,6 +193,7 @@ func readFromKeychain(key: String) -> String? {
let query: [String: Any] = [ let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key, kSecAttrAccount as String: key,
kSecAttrService as String: "flutter_secure_storage_service",
kSecReturnData as String: kCFBooleanTrue!, kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne, kSecMatchLimit as String: kSecMatchLimitOne,
kSecAttrAccessGroup as String: "CN332ZUGRP.eu.twonly.shared", // Use your access group kSecAttrAccessGroup as String: "CN332ZUGRP.eu.twonly.shared", // Use your access group
@ -205,6 +211,36 @@ func readFromKeychain(key: String) -> String? {
return nil return nil
} }
// Helper function to write to Keychain
func writeToKeychain(key: String, value: String) {
guard let data = value.data(using: .utf8) else {
NSLog("Failed to convert value to data for keychain key: \(key)")
return
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: "flutter_secure_storage_service",
kSecAttrAccessGroup as String: "CN332ZUGRP.eu.twonly.shared"
]
// Delete existing item first to ensure a clean overwrite
SecItemDelete(query as CFDictionary)
// Add the new item with background-compatible accessibility
var addQuery = query
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status != errSecSuccess {
NSLog("Failed to write keychain item for key \(key): \(status)")
} else {
NSLog("Successfully wrote keychain item for key: \(key)")
}
}
func getPushNotificationText(pushNotification: PushNotification, userKnown: Bool) -> (String, String) { func getPushNotificationText(pushNotification: PushNotification, userKnown: Bool) -> (String, String) {
let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" // Get the current system language let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" // Get the current system language

View file

@ -28,17 +28,9 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
flutter_ios_podfile_setup flutter_ios_podfile_setup
pod 'Firebase', :modular_headers => true
pod 'FirebaseMessaging', :modular_headers => true
pod 'FirebaseCoreInternal', :modular_headers => true
pod 'GoogleUtilities', :modular_headers => true
pod 'FirebaseCore', :modular_headers => true
pod 'SwiftProtobuf'
# pod 'sqlite3', :modular_headers => true
target 'Runner' do target 'Runner' do
pod 'SwiftProtobuf'
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do target 'RunnerTests' do
inherit! :search_paths inherit! :search_paths
@ -76,6 +68,28 @@ post_install do |installer|
## dart: PermissionGroup.mediaLibrary ## dart: PermissionGroup.mediaLibrary
'PERMISSION_PHOTOS=1', 'PERMISSION_PHOTOS=1',
## dart: PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse
'PERMISSION_LOCATION=0',
## dart: PermissionGroup.contacts
'PERMISSION_CONTACTS=0',
## dart: PermissionGroup.calendar, PermissionGroup.reminders
'PERMISSION_EVENTS=0',
'PERMISSION_REMINDERS=0',
## dart: PermissionGroup.speech
'PERMISSION_SPEECH_RECOGNITION=0',
## dart: PermissionGroup.bluetooth
'PERMISSION_BLUETOOTH=0',
## dart: PermissionGroup.appTrackingTransparency
'PERMISSION_APP_TRACKING_TRANSPARENCY=0',
## dart: PermissionGroup.sensors
'PERMISSION_SENSORS=0',
] ]
end end
@ -83,5 +97,6 @@ post_install do |installer|
end end
target 'NotificationService' do target 'NotificationService' do
pod 'SwiftProtobuf'
# pod 'Firebase/Messaging' # pod 'Firebase/Messaging'
end end

View file

@ -1,136 +1,18 @@
PODS: PODS:
- app_links (7.0.0):
- Flutter
- audio_waveforms (0.0.1): - audio_waveforms (0.0.1):
- Flutter - Flutter
- background_downloader (0.0.1):
- Flutter
- camera_avfoundation (0.0.1):
- Flutter
- connectivity_plus (0.0.1):
- Flutter
- cryptography_flutter_plus (0.2.0): - cryptography_flutter_plus (0.2.0):
- Flutter - Flutter
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- emoji_picker_flutter (0.0.1):
- Flutter
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase (12.9.0):
- Firebase/Core (= 12.9.0)
- Firebase/Core (12.9.0):
- Firebase/CoreOnly
- FirebaseAnalytics (~> 12.9.0)
- Firebase/CoreOnly (12.9.0):
- FirebaseCore (~> 12.9.0)
- Firebase/Installations (12.9.0):
- Firebase/CoreOnly
- FirebaseInstallations (~> 12.9.0)
- Firebase/Messaging (12.9.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.9.0)
- firebase_app_installations (0.4.1):
- Firebase/Installations (= 12.9.0)
- firebase_core
- Flutter
- firebase_core (4.6.0):
- Firebase/CoreOnly (= 12.9.0)
- Flutter
- firebase_messaging (16.1.3):
- Firebase/Messaging (= 12.9.0)
- firebase_core
- Flutter
- FirebaseAnalytics (12.9.0):
- FirebaseAnalytics/Default (= 12.9.0)
- FirebaseCore (~> 12.9.0)
- FirebaseInstallations (~> 12.9.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseAnalytics/Default (12.9.0):
- FirebaseCore (~> 12.9.0)
- FirebaseInstallations (~> 12.9.0)
- GoogleAppMeasurement/Default (= 12.9.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseCore (12.9.0):
- FirebaseCoreInternal (~> 12.9.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreInternal (12.9.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseInstallations (12.9.0):
- FirebaseCore (~> 12.9.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (12.9.0):
- FirebaseCore (~> 12.9.0)
- FirebaseInstallations (~> 12.9.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Reachability (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_image_compress_common (1.0.0): - flutter_image_compress_common (1.0.0):
- Flutter - Flutter
- Mantle - Mantle
- SDWebImage - SDWebImage
- SDWebImageWebPCoder - SDWebImageWebPCoder
- flutter_keyboard_visibility_temp_fork (0.0.1):
- Flutter
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
- flutter_sharing_intent (1.0.1): - flutter_sharing_intent (1.0.1):
- Flutter - Flutter
- flutter_volume_controller (0.0.1): - flutter_volume_controller (0.0.1):
- Flutter - Flutter
- gal (1.0.0):
- Flutter
- FlutterMacOS
- google_mlkit_barcode_scanning (0.14.2): - google_mlkit_barcode_scanning (0.14.2):
- Flutter - Flutter
- google_mlkit_commons - google_mlkit_commons
@ -142,33 +24,6 @@ PODS:
- Flutter - Flutter
- google_mlkit_commons - google_mlkit_commons
- GoogleMLKit/FaceDetection (~> 9.0.0) - GoogleMLKit/FaceDetection (~> 9.0.0)
- GoogleAdsOnDeviceConversion (3.2.0):
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Core (12.9.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Default (12.9.0):
- GoogleAdsOnDeviceConversion (~> 3.2.0)
- GoogleAppMeasurement/Core (= 12.9.0)
- GoogleAppMeasurement/IdentitySupport (= 12.9.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/IdentitySupport (12.9.0):
- GoogleAppMeasurement/Core (= 12.9.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0): - GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4) - PromisesObjC (~> 2.4)
@ -185,54 +40,16 @@ PODS:
- GoogleToolboxForMac/Defines (= 4.2.1) - GoogleToolboxForMac/Defines (= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (4.2.1)": - "GoogleToolboxForMac/NSData+zlib (4.2.1)":
- GoogleToolboxForMac/Defines (= 4.2.1) - GoogleToolboxForMac/Defines (= 4.2.1)
- GoogleUtilities (8.1.0):
- GoogleUtilities/AppDelegateSwizzler (= 8.1.0)
- GoogleUtilities/Environment (= 8.1.0)
- GoogleUtilities/Logger (= 8.1.0)
- GoogleUtilities/MethodSwizzler (= 8.1.0)
- GoogleUtilities/Network (= 8.1.0)
- "GoogleUtilities/NSData+zlib (= 8.1.0)"
- GoogleUtilities/Privacy (= 8.1.0)
- GoogleUtilities/Reachability (= 8.1.0)
- GoogleUtilities/SwizzlerTestHelpers (= 8.1.0)
- GoogleUtilities/UserDefaults (= 8.1.0)
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.1.0): - GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0): - GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment - GoogleUtilities/Environment
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- GoogleUtilities/MethodSwizzler (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.0)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0) - GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/Reachability (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/SwizzlerTestHelpers (8.1.0):
- GoogleUtilities/MethodSwizzler
- GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- GTMSessionFetcher/Core (3.5.0) - GTMSessionFetcher/Core (3.5.0)
- image_picker_ios (0.0.1):
- Flutter
- in_app_purchase_storekit (0.0.1):
- Flutter
- FlutterMacOS
- integration_test (0.0.1):
- Flutter
- libwebp (1.5.0): - libwebp (1.5.0):
- libwebp/demux (= 1.5.0) - libwebp/demux (= 1.5.0)
- libwebp/mux (= 1.5.0) - libwebp/mux (= 1.5.0)
@ -245,9 +62,6 @@ PODS:
- libwebp/sharpyuv (1.5.0) - libwebp/sharpyuv (1.5.0)
- libwebp/webp (1.5.0): - libwebp/webp (1.5.0):
- libwebp/sharpyuv - libwebp/sharpyuv
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
- Mantle (2.2.0): - Mantle (2.2.0):
- Mantle/extobjc (= 2.2.0) - Mantle/extobjc (= 2.2.0)
- Mantle/extobjc (2.2.0) - Mantle/extobjc (2.2.0)
@ -276,18 +90,11 @@ PODS:
- nanopb/encode (= 3.30910.0) - nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0) - nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0) - nanopb/encode (3.30910.0)
- package_info_plus (0.4.5):
- 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)
- restart_app (1.7.3):
- Flutter
- rust_lib_twonly (0.0.1): - rust_lib_twonly (0.0.1):
- Flutter - Flutter
- screen_protector (1.5.1): - screen_protector (1.5.1):
@ -300,90 +107,29 @@ PODS:
- SDWebImageWebPCoder (0.15.0): - SDWebImageWebPCoder (0.15.0):
- libwebp (~> 1.0) - libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17) - SDWebImage/Core (~> 5.17)
- Sentry/HybridSDK (8.58.0) - SwiftProtobuf (1.38.0)
- sentry_flutter (9.16.0):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.58.0)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- SwiftProtobuf (1.36.1)
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- workmanager_apple (0.0.1): - workmanager_apple (0.0.1):
- Flutter - Flutter
DEPENDENCIES: DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
- audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`)
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- cryptography_flutter_plus (from `.symlinks/plugins/cryptography_flutter_plus/ios`) - cryptography_flutter_plus (from `.symlinks/plugins/cryptography_flutter_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Firebase
- firebase_app_installations (from `.symlinks/plugins/firebase_app_installations/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- FirebaseCore
- FirebaseCoreInternal
- FirebaseMessaging
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) - flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`)
- flutter_keyboard_visibility_temp_fork (from `.symlinks/plugins/flutter_keyboard_visibility_temp_fork/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- flutter_sharing_intent (from `.symlinks/plugins/flutter_sharing_intent/ios`) - flutter_sharing_intent (from `.symlinks/plugins/flutter_sharing_intent/ios`)
- flutter_volume_controller (from `.symlinks/plugins/flutter_volume_controller/ios`) - flutter_volume_controller (from `.symlinks/plugins/flutter_volume_controller/ios`)
- gal (from `.symlinks/plugins/gal/darwin`)
- google_mlkit_barcode_scanning (from `.symlinks/plugins/google_mlkit_barcode_scanning/ios`) - google_mlkit_barcode_scanning (from `.symlinks/plugins/google_mlkit_barcode_scanning/ios`)
- google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`) - google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`)
- google_mlkit_face_detection (from `.symlinks/plugins/google_mlkit_face_detection/ios`) - google_mlkit_face_detection (from `.symlinks/plugins/google_mlkit_face_detection/ios`)
- GoogleUtilities
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- 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`)
- rust_lib_twonly (from `.symlinks/plugins/rust_lib_twonly/ios`) - rust_lib_twonly (from `.symlinks/plugins/rust_lib_twonly/ios`)
- screen_protector (from `.symlinks/plugins/screen_protector/ios`) - screen_protector (from `.symlinks/plugins/screen_protector/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- SwiftProtobuf - SwiftProtobuf
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`) - workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- DKImagePickerController
- DKPhotoGallery
- Firebase
- FirebaseAnalytics
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- GoogleAdsOnDeviceConversion
- GoogleAppMeasurement
- GoogleDataTransport - GoogleDataTransport
- GoogleMLKit - GoogleMLKit
- GoogleToolboxForMac - GoogleToolboxForMac
@ -401,138 +147,54 @@ SPEC REPOS:
- ScreenProtectorKit - ScreenProtectorKit
- SDWebImage - SDWebImage
- SDWebImageWebPCoder - SDWebImageWebPCoder
- Sentry
- SwiftProtobuf - SwiftProtobuf
- SwiftyGif
EXTERNAL SOURCES: EXTERNAL SOURCES:
app_links:
:path: ".symlinks/plugins/app_links/ios"
audio_waveforms: audio_waveforms:
:path: ".symlinks/plugins/audio_waveforms/ios" :path: ".symlinks/plugins/audio_waveforms/ios"
background_downloader:
:path: ".symlinks/plugins/background_downloader/ios"
camera_avfoundation:
:path: ".symlinks/plugins/camera_avfoundation/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
cryptography_flutter_plus: cryptography_flutter_plus:
:path: ".symlinks/plugins/cryptography_flutter_plus/ios" :path: ".symlinks/plugins/cryptography_flutter_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
emoji_picker_flutter:
:path: ".symlinks/plugins/emoji_picker_flutter/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_app_installations:
:path: ".symlinks/plugins/firebase_app_installations/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_image_compress_common: flutter_image_compress_common:
:path: ".symlinks/plugins/flutter_image_compress_common/ios" :path: ".symlinks/plugins/flutter_image_compress_common/ios"
flutter_keyboard_visibility_temp_fork:
:path: ".symlinks/plugins/flutter_keyboard_visibility_temp_fork/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
flutter_sharing_intent: flutter_sharing_intent:
:path: ".symlinks/plugins/flutter_sharing_intent/ios" :path: ".symlinks/plugins/flutter_sharing_intent/ios"
flutter_volume_controller: flutter_volume_controller:
:path: ".symlinks/plugins/flutter_volume_controller/ios" :path: ".symlinks/plugins/flutter_volume_controller/ios"
gal:
:path: ".symlinks/plugins/gal/darwin"
google_mlkit_barcode_scanning: google_mlkit_barcode_scanning:
:path: ".symlinks/plugins/google_mlkit_barcode_scanning/ios" :path: ".symlinks/plugins/google_mlkit_barcode_scanning/ios"
google_mlkit_commons: google_mlkit_commons:
:path: ".symlinks/plugins/google_mlkit_commons/ios" :path: ".symlinks/plugins/google_mlkit_commons/ios"
google_mlkit_face_detection: google_mlkit_face_detection:
:path: ".symlinks/plugins/google_mlkit_face_detection/ios" :path: ".symlinks/plugins/google_mlkit_face_detection/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
in_app_purchase_storekit:
:path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
package_info_plus:
: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:
:path: ".symlinks/plugins/restart_app/ios"
rust_lib_twonly: rust_lib_twonly:
:path: ".symlinks/plugins/rust_lib_twonly/ios" :path: ".symlinks/plugins/rust_lib_twonly/ios"
screen_protector: screen_protector:
:path: ".symlinks/plugins/screen_protector/ios" :path: ".symlinks/plugins/screen_protector/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
workmanager_apple: workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios" :path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
app_links: a754cbec3c255bd4bbb4d236ecc06f28cd9a7ce8
audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
camera_avfoundation: 968a9a5323c79a99c166ad9d7866bfd2047b5a9b
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
cryptography_flutter_plus: 44f4e9e4079395fcbb3e7809c0ac2c6ae2d9576f cryptography_flutter_plus: 44f4e9e4079395fcbb3e7809c0ac2c6ae2d9576f
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Firebase: 065f2bb395062046623036d8e6dc857bc2521d56
firebase_app_installations: 1abd8d071ea2022d7888f7a9713710c37136ff91
firebase_core: 8e6f58412ca227827c366b92e7cee047a2148c60
firebase_messaging: c3aa897e0d40109cfb7927c40dc0dea799863f3b
FirebaseAnalytics: cd7d01d352f3c237c9a0e31552c257cd0b0c0352
FirebaseCore: 428912f751178b06bef0a1793effeb4a5e09a9b8
FirebaseCoreInternal: b321eafae5362113bc182956fafc9922cfc77b72
FirebaseInstallations: 7b64ffd006032b2b019a59b803858df5112d9eaa
FirebaseMessaging: 7d6cdbff969127c4151c824fe432f0e301210f15
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
flutter_keyboard_visibility_temp_fork: 95b2d534bacf6ac62e7fcbe5c2a9e2c2a17ce06f
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
flutter_sharing_intent: 0c1e53949f09fa8df8ac2268505687bde8ff264c flutter_sharing_intent: 0c1e53949f09fa8df8ac2268505687bde8ff264c
flutter_volume_controller: c2be490cb0487e8b88d0d9fc2b7e1c139a4ebccb flutter_volume_controller: c2be490cb0487e8b88d0d9fc2b7e1c139a4ebccb
gal: baecd024ebfd13c441269ca7404792a7152fde89
google_mlkit_barcode_scanning: 12d8422d8f7b00726dedf9cac00188a2b98750c2 google_mlkit_barcode_scanning: 12d8422d8f7b00726dedf9cac00188a2b98750c2
google_mlkit_commons: a5e4ffae5bc59ea4c7b9025dc72cb6cb79dc1166 google_mlkit_commons: a5e4ffae5bc59ea4c7b9025dc72cb6cb79dc1166
google_mlkit_face_detection: ee4b72cfae062b4c972204be955d83055a4bfd36 google_mlkit_face_detection: ee4b72cfae062b4c972204be955d83055a4bfd36
GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f
GoogleAppMeasurement: fce7c1c90640d2f9f5c56771f71deacb2ba3f98c
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleMLKit: b1eee21a41c57704fe72483b15c85cb2c0cd7444 GoogleMLKit: b1eee21a41c57704fe72483b15c85cb2c0cd7444
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0 MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0
MLKitBarcodeScanning: 39de223e7b1b8a8fbf10816a536dd292d8a39343 MLKitBarcodeScanning: 39de223e7b1b8a8fbf10816a536dd292d8a39343
@ -540,28 +202,17 @@ SPEC CHECKSUMS:
MLKitFaceDetection: 32549f1e70e6e7731261bf9cea2b74095e2531cb MLKitFaceDetection: 32549f1e70e6e7731261bf9cea2b74095e2531cb
MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961 MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
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
rust_lib_twonly: 73165b05d0cda50db45852db63f49caa7f319520 rust_lib_twonly: 73165b05d0cda50db45852db63f49caa7f319520
screen_protector: 18c6aca2dc5d2a832f6787a5318f97f03e9d3150 screen_protector: 18c6aca2dc5d2a832f6787a5318f97f03e9d3150
ScreenProtectorKit: 6ceb3e0808341a9bc15d175bff40dfdd4b32da71 ScreenProtectorKit: 6ceb3e0808341a9bc15d175bff40dfdd4b32da71
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be SwiftProtobuf: d724b5145bfc609d9a49c1e3e3a3dabb07273ffb
sentry_flutter: 31101687061fb85211ebab09ce6eb8db4e9ba74f
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftProtobuf: 9e106a71456f4d3f6a3b0c8fd87ef0be085efc38
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: ae041999f13ba7b2285ff9ad9bc688ed647bbcb7 PODFILE CHECKSUM: 245e6d5f26c858edb6b99a7d972cc93ead4d55cf
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View file

@ -23,6 +23,7 @@
D25D4D7A2EFF41DB0029F805 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D25D4D702EFF41DB0029F805 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D25D4D7A2EFF41DB0029F805 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D25D4D702EFF41DB0029F805 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D2B2E0FF2F63819600E729C1 /* VideoCompressionChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B2E0FE2F63819600E729C1 /* VideoCompressionChannel.swift */; }; D2B2E0FF2F63819600E729C1 /* VideoCompressionChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B2E0FE2F63819600E729C1 /* VideoCompressionChannel.swift */; };
F3C66D726A2EB28484DF0B10 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 16FBC6F5B58E1C6646F5D447 /* GoogleService-Info.plist */; }; F3C66D726A2EB28484DF0B10 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 16FBC6F5B58E1C6646F5D447 /* GoogleService-Info.plist */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -114,6 +115,7 @@
E96A5ACA32A7118204F050A5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; }; E96A5ACA32A7118204F050A5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
EE2CCFEE4ABECF33852F7735 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EE2CCFEE4ABECF33852F7735 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
F02F7A1D63544AA9F23A1085 /* Pods-NotificationService.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.profile.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.profile.xcconfig"; sourceTree = "<group>"; }; F02F7A1D63544AA9F23A1085 /* Pods-NotificationService.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.profile.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.profile.xcconfig"; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -165,6 +167,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
CA4FDF5DD8F229C30DE512AF /* Pods_Runner.framework in Frameworks */, CA4FDF5DD8F229C30DE512AF /* Pods_Runner.framework in Frameworks */,
D25D4D1E2EF626E30029F805 /* StoreKit.framework in Frameworks */, D25D4D1E2EF626E30029F805 /* StoreKit.framework in Frameworks */,
); );
@ -200,6 +203,7 @@
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */, 9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@ -307,6 +311,9 @@
productType = "com.apple.product-type.bundle.unit-test"; productType = "com.apple.product-type.bundle.unit-test";
}; };
97C146ED1CF9000F007C117D /* Runner */ = { 97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = ( buildPhases = (
@ -378,6 +385,9 @@
/* Begin PBXProject section */ /* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = { 97C146E61CF9000F007C117D /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
@ -1309,6 +1319,18 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = 97C146E61CF9000F007C117D /* Project object */; rootObject = 97C146E61CF9000F007C117D /* Project object */;
} }

View file

@ -0,0 +1,194 @@
{
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
"version" : "11.2.0"
}
},
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382",
"version" : "12.14.0"
}
},
{
"identity" : "flutterfire",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/flutterfire",
"state" : {
"revision" : "a10a4148e769fadb01b1ff8d6bb76e9137f35b81",
"version" : "4.6.0-firebase-core-swift"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
"version" : "3.6.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "219e564a8510e983e675c94f77f7f7c50049f22d",
"version" : "12.14.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "sentry-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
"version" : "8.58.0"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}

View file

@ -5,6 +5,24 @@
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"

View file

@ -0,0 +1,194 @@
{
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
"version" : "11.2.0"
}
},
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382",
"version" : "12.14.0"
}
},
{
"identity" : "flutterfire",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/flutterfire",
"state" : {
"revision" : "a10a4148e769fadb01b1ff8d6bb76e9137f35b81",
"version" : "4.6.0-firebase-core-swift"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
"version" : "3.6.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "219e564a8510e983e675c94f77f7f7c50049f22d",
"version" : "12.14.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "sentry-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
"version" : "8.58.0"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}

View file

@ -55,6 +55,8 @@
<string>Use your microphone to enable audio when making videos.</string> <string>Use your microphone to enable audio when making videos.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>twonly will save photos or videos to your library.</string> <string>twonly will save photos or videos to your library.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app does not use or store your location information.</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>

View file

@ -208,7 +208,8 @@ class _AppMainWidgetState extends State<AppMainWidget> {
_isTwonlyLocked = false; _isTwonlyLocked = false;
}), }),
); );
} else if (!userService.currentUser.skipSetupPages && userService.currentUser.currentSetupPage != null) { } else if (!userService.currentUser.skipSetupPages &&
userService.currentUser.currentSetupPage != null) {
// This will only be shown in case the user have not skipped // This will only be shown in case the user have not skipped
child = SetupView( child = SetupView(
onUpdate: () => setState(() { onUpdate: () => setState(() {

View file

@ -9,8 +9,10 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
// These functions are ignored because they are not marked as `pub`: `get_callbacks` // These functions are ignored because they are not marked as `pub`: `get_callbacks`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `FlutterCallbacks`, `Logging`, `UserDiscoveryCallbacks` // These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `FlutterCallbacks`, `Logging`, `UserDiscoveryCallbacks`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`
Future<void> initFlutterCallbacks({ Future<void> initFlutterCallbacks({
required int callbackId,
required FutureOr<RustStreamSink<String>> Function() loggingGetStreamSink, required FutureOr<RustStreamSink<String>> Function() loggingGetStreamSink,
required FutureOr<Uint8List?> Function(Uint8List) userDiscoverySignData, required FutureOr<Uint8List?> Function(Uint8List) userDiscoverySignData,
required FutureOr<bool> Function(Uint8List, Uint8List, Uint8List) required FutureOr<bool> Function(Uint8List, Uint8List, Uint8List)
@ -39,6 +41,7 @@ Future<void> initFlutterCallbacks({
required FutureOr<Uint8List?> Function(PlatformInt64) required FutureOr<Uint8List?> Function(PlatformInt64)
userDiscoveryGetContactPromotion, userDiscoveryGetContactPromotion,
}) => RustLib.instance.api.crateBridgeCallbacksInitFlutterCallbacks( }) => RustLib.instance.api.crateBridgeCallbacksInitFlutterCallbacks(
callbackId: callbackId,
loggingGetStreamSink: loggingGetStreamSink, loggingGetStreamSink: loggingGetStreamSink,
userDiscoverySignData: userDiscoverySignData, userDiscoverySignData: userDiscoverySignData,
userDiscoveryVerifySignature: userDiscoveryVerifySignature, userDiscoveryVerifySignature: userDiscoveryVerifySignature,

View file

@ -9,36 +9,45 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
class FlutterUserDiscovery { class FlutterUserDiscovery {
const FlutterUserDiscovery(); const FlutterUserDiscovery();
static Future<Uint8List> getCurrentVersion() => RustLib.instance.api static Future<Uint8List> getCurrentVersion({required int callbackId}) =>
.crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersion(); RustLib.instance.api
.crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersion(
callbackId: callbackId,
);
static Future<List<Uint8List>> getNewMessages({ static Future<List<Uint8List>> getNewMessages({
required int callbackId,
required PlatformInt64 contactId, required PlatformInt64 contactId,
required List<int> receivedVersion, required List<int> receivedVersion,
}) => RustLib.instance.api }) => RustLib.instance.api
.crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessages( .crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessages(
callbackId: callbackId,
contactId: contactId, contactId: contactId,
receivedVersion: receivedVersion, receivedVersion: receivedVersion,
); );
static Future<void> handleNewMessages({ static Future<void> handleNewMessages({
required int callbackId,
required PlatformInt64 contactId, required PlatformInt64 contactId,
PlatformInt64? publicKeyVerifiedTimestamp, PlatformInt64? publicKeyVerifiedTimestamp,
required List<Uint8List> messages, required List<Uint8List> messages,
}) => RustLib.instance.api }) => RustLib.instance.api
.crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessages( .crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessages(
callbackId: callbackId,
contactId: contactId, contactId: contactId,
publicKeyVerifiedTimestamp: publicKeyVerifiedTimestamp, publicKeyVerifiedTimestamp: publicKeyVerifiedTimestamp,
messages: messages, messages: messages,
); );
static Future<void> initializeOrUpdate({ static Future<void> initializeOrUpdate({
required int callbackId,
required int threshold, required int threshold,
required PlatformInt64 userId, required PlatformInt64 userId,
required List<int> publicKey, required List<int> publicKey,
required bool sharePromotion, required bool sharePromotion,
}) => RustLib.instance.api }) => RustLib.instance.api
.crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdate( .crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdate(
callbackId: callbackId,
threshold: threshold, threshold: threshold,
userId: userId, userId: userId,
publicKey: publicKey, publicKey: publicKey,
@ -46,19 +55,23 @@ class FlutterUserDiscovery {
); );
static Future<Uint8List?> shouldRequestNewMessages({ static Future<Uint8List?> shouldRequestNewMessages({
required int callbackId,
required PlatformInt64 contactId, required PlatformInt64 contactId,
required List<int> version, required List<int> version,
}) => RustLib.instance.api }) => RustLib.instance.api
.crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessages( .crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessages(
callbackId: callbackId,
contactId: contactId, contactId: contactId,
version: version, version: version,
); );
static Future<void> updateVerificationStateForUser({ static Future<void> updateVerificationStateForUser({
required int callbackId,
required PlatformInt64 contactId, required PlatformInt64 contactId,
PlatformInt64? publicKeyVerifiedTimestamp, PlatformInt64? publicKeyVerifiedTimestamp,
}) => RustLib.instance.api }) => RustLib.instance.api
.crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUser( .crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUser(
callbackId: callbackId,
contactId: contactId, contactId: contactId,
publicKeyVerifiedTimestamp: publicKeyVerifiedTimestamp, publicKeyVerifiedTimestamp: publicKeyVerifiedTimestamp,
); );

View file

@ -87,16 +87,20 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
abstract class RustLibApi extends BaseApi { abstract class RustLibApi extends BaseApi {
Future<Uint8List> Future<Uint8List>
crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersion(); crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersion({
required int callbackId,
});
Future<List<Uint8List>> Future<List<Uint8List>>
crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessages({ crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessages({
required int callbackId,
required PlatformInt64 contactId, required PlatformInt64 contactId,
required List<int> receivedVersion, required List<int> receivedVersion,
}); });
Future<void> Future<void>
crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessages({ crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessages({
required int callbackId,
required PlatformInt64 contactId, required PlatformInt64 contactId,
PlatformInt64? publicKeyVerifiedTimestamp, PlatformInt64? publicKeyVerifiedTimestamp,
required List<Uint8List> messages, required List<Uint8List> messages,
@ -104,6 +108,7 @@ abstract class RustLibApi extends BaseApi {
Future<void> Future<void>
crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdate({ crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdate({
required int callbackId,
required int threshold, required int threshold,
required PlatformInt64 userId, required PlatformInt64 userId,
required List<int> publicKey, required List<int> publicKey,
@ -112,17 +117,20 @@ abstract class RustLibApi extends BaseApi {
Future<Uint8List?> Future<Uint8List?>
crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessages({ crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessages({
required int callbackId,
required PlatformInt64 contactId, required PlatformInt64 contactId,
required List<int> version, required List<int> version,
}); });
Future<void> Future<void>
crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUser({ crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUser({
required int callbackId,
required PlatformInt64 contactId, required PlatformInt64 contactId,
PlatformInt64? publicKeyVerifiedTimestamp, PlatformInt64? publicKeyVerifiedTimestamp,
}); });
Future<void> crateBridgeCallbacksInitFlutterCallbacks({ Future<void> crateBridgeCallbacksInitFlutterCallbacks({
required int callbackId,
required FutureOr<RustStreamSink<String>> Function() loggingGetStreamSink, required FutureOr<RustStreamSink<String>> Function() loggingGetStreamSink,
required FutureOr<Uint8List?> Function(Uint8List) userDiscoverySignData, required FutureOr<Uint8List?> Function(Uint8List) userDiscoverySignData,
required FutureOr<bool> Function(Uint8List, Uint8List, Uint8List) required FutureOr<bool> Function(Uint8List, Uint8List, Uint8List)
@ -242,11 +250,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
@override @override
Future<Uint8List> Future<Uint8List>
crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersion() { crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersion({
required int callbackId,
}) {
return handler.executeNormal( return handler.executeNormal(
NormalTask( NormalTask(
callFfi: (port_) { callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_u_32(callbackId, serializer);
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
@ -260,7 +271,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
), ),
constMeta: constMeta:
kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersionConstMeta, kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersionConstMeta,
argValues: [], argValues: [callbackId],
apiImpl: this, apiImpl: this,
), ),
); );
@ -270,12 +281,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersionConstMeta => get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetCurrentVersionConstMeta =>
const TaskConstMeta( const TaskConstMeta(
debugName: "flutter_user_discovery_get_current_version", debugName: "flutter_user_discovery_get_current_version",
argNames: [], argNames: ["callbackId"],
); );
@override @override
Future<List<Uint8List>> Future<List<Uint8List>>
crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessages({ crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessages({
required int callbackId,
required PlatformInt64 contactId, required PlatformInt64 contactId,
required List<int> receivedVersion, required List<int> receivedVersion,
}) { }) {
@ -283,6 +295,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
NormalTask( NormalTask(
callFfi: (port_) { callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_u_32(callbackId, serializer);
sse_encode_i_64(contactId, serializer); sse_encode_i_64(contactId, serializer);
sse_encode_list_prim_u_8_loose(receivedVersion, serializer); sse_encode_list_prim_u_8_loose(receivedVersion, serializer);
pdeCallFfi( pdeCallFfi(
@ -298,7 +311,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
), ),
constMeta: constMeta:
kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessagesConstMeta, kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessagesConstMeta,
argValues: [contactId, receivedVersion], argValues: [callbackId, contactId, receivedVersion],
apiImpl: this, apiImpl: this,
), ),
); );
@ -308,12 +321,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessagesConstMeta => get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryGetNewMessagesConstMeta =>
const TaskConstMeta( const TaskConstMeta(
debugName: "flutter_user_discovery_get_new_messages", debugName: "flutter_user_discovery_get_new_messages",
argNames: ["contactId", "receivedVersion"], argNames: ["callbackId", "contactId", "receivedVersion"],
); );
@override @override
Future<void> Future<void>
crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessages({ crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessages({
required int callbackId,
required PlatformInt64 contactId, required PlatformInt64 contactId,
PlatformInt64? publicKeyVerifiedTimestamp, PlatformInt64? publicKeyVerifiedTimestamp,
required List<Uint8List> messages, required List<Uint8List> messages,
@ -322,6 +336,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
NormalTask( NormalTask(
callFfi: (port_) { callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_u_32(callbackId, serializer);
sse_encode_i_64(contactId, serializer); sse_encode_i_64(contactId, serializer);
sse_encode_opt_box_autoadd_i_64( sse_encode_opt_box_autoadd_i_64(
publicKeyVerifiedTimestamp, publicKeyVerifiedTimestamp,
@ -341,7 +356,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
), ),
constMeta: constMeta:
kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessagesConstMeta, kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessagesConstMeta,
argValues: [contactId, publicKeyVerifiedTimestamp, messages], argValues: [
callbackId,
contactId,
publicKeyVerifiedTimestamp,
messages,
],
apiImpl: this, apiImpl: this,
), ),
); );
@ -351,12 +371,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessagesConstMeta => get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryHandleNewMessagesConstMeta =>
const TaskConstMeta( const TaskConstMeta(
debugName: "flutter_user_discovery_handle_new_messages", debugName: "flutter_user_discovery_handle_new_messages",
argNames: ["contactId", "publicKeyVerifiedTimestamp", "messages"], argNames: [
"callbackId",
"contactId",
"publicKeyVerifiedTimestamp",
"messages",
],
); );
@override @override
Future<void> Future<void>
crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdate({ crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdate({
required int callbackId,
required int threshold, required int threshold,
required PlatformInt64 userId, required PlatformInt64 userId,
required List<int> publicKey, required List<int> publicKey,
@ -366,6 +392,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
NormalTask( NormalTask(
callFfi: (port_) { callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_u_32(callbackId, serializer);
sse_encode_u_8(threshold, serializer); sse_encode_u_8(threshold, serializer);
sse_encode_i_64(userId, serializer); sse_encode_i_64(userId, serializer);
sse_encode_list_prim_u_8_loose(publicKey, serializer); sse_encode_list_prim_u_8_loose(publicKey, serializer);
@ -383,7 +410,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
), ),
constMeta: constMeta:
kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdateConstMeta, kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdateConstMeta,
argValues: [threshold, userId, publicKey, sharePromotion], argValues: [callbackId, threshold, userId, publicKey, sharePromotion],
apiImpl: this, apiImpl: this,
), ),
); );
@ -393,12 +420,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdateConstMeta => get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryInitializeOrUpdateConstMeta =>
const TaskConstMeta( const TaskConstMeta(
debugName: "flutter_user_discovery_initialize_or_update", debugName: "flutter_user_discovery_initialize_or_update",
argNames: ["threshold", "userId", "publicKey", "sharePromotion"], argNames: [
"callbackId",
"threshold",
"userId",
"publicKey",
"sharePromotion",
],
); );
@override @override
Future<Uint8List?> Future<Uint8List?>
crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessages({ crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessages({
required int callbackId,
required PlatformInt64 contactId, required PlatformInt64 contactId,
required List<int> version, required List<int> version,
}) { }) {
@ -406,6 +440,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
NormalTask( NormalTask(
callFfi: (port_) { callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_u_32(callbackId, serializer);
sse_encode_i_64(contactId, serializer); sse_encode_i_64(contactId, serializer);
sse_encode_list_prim_u_8_loose(version, serializer); sse_encode_list_prim_u_8_loose(version, serializer);
pdeCallFfi( pdeCallFfi(
@ -421,7 +456,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
), ),
constMeta: constMeta:
kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessagesConstMeta, kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessagesConstMeta,
argValues: [contactId, version], argValues: [callbackId, contactId, version],
apiImpl: this, apiImpl: this,
), ),
); );
@ -431,12 +466,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessagesConstMeta => get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryShouldRequestNewMessagesConstMeta =>
const TaskConstMeta( const TaskConstMeta(
debugName: "flutter_user_discovery_should_request_new_messages", debugName: "flutter_user_discovery_should_request_new_messages",
argNames: ["contactId", "version"], argNames: ["callbackId", "contactId", "version"],
); );
@override @override
Future<void> Future<void>
crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUser({ crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUser({
required int callbackId,
required PlatformInt64 contactId, required PlatformInt64 contactId,
PlatformInt64? publicKeyVerifiedTimestamp, PlatformInt64? publicKeyVerifiedTimestamp,
}) { }) {
@ -444,6 +480,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
NormalTask( NormalTask(
callFfi: (port_) { callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_u_32(callbackId, serializer);
sse_encode_i_64(contactId, serializer); sse_encode_i_64(contactId, serializer);
sse_encode_opt_box_autoadd_i_64( sse_encode_opt_box_autoadd_i_64(
publicKeyVerifiedTimestamp, publicKeyVerifiedTimestamp,
@ -462,7 +499,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
), ),
constMeta: constMeta:
kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUserConstMeta, kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUserConstMeta,
argValues: [contactId, publicKeyVerifiedTimestamp], argValues: [callbackId, contactId, publicKeyVerifiedTimestamp],
apiImpl: this, apiImpl: this,
), ),
); );
@ -472,11 +509,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUserConstMeta => get kCrateBridgeWrapperUserDiscoveryFlutterUserDiscoveryUpdateVerificationStateForUserConstMeta =>
const TaskConstMeta( const TaskConstMeta(
debugName: "flutter_user_discovery_update_verification_state_for_user", debugName: "flutter_user_discovery_update_verification_state_for_user",
argNames: ["contactId", "publicKeyVerifiedTimestamp"], argNames: ["callbackId", "contactId", "publicKeyVerifiedTimestamp"],
); );
@override @override
Future<void> crateBridgeCallbacksInitFlutterCallbacks({ Future<void> crateBridgeCallbacksInitFlutterCallbacks({
required int callbackId,
required FutureOr<RustStreamSink<String>> Function() loggingGetStreamSink, required FutureOr<RustStreamSink<String>> Function() loggingGetStreamSink,
required FutureOr<Uint8List?> Function(Uint8List) userDiscoverySignData, required FutureOr<Uint8List?> Function(Uint8List) userDiscoverySignData,
required FutureOr<bool> Function(Uint8List, Uint8List, Uint8List) required FutureOr<bool> Function(Uint8List, Uint8List, Uint8List)
@ -513,6 +551,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
NormalTask( NormalTask(
callFfi: (port_) { callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_u_32(callbackId, serializer);
sse_encode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException( sse_encode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(
loggingGetStreamSink, loggingGetStreamSink,
serializer, serializer,
@ -586,6 +625,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
), ),
constMeta: kCrateBridgeCallbacksInitFlutterCallbacksConstMeta, constMeta: kCrateBridgeCallbacksInitFlutterCallbacksConstMeta,
argValues: [ argValues: [
callbackId,
loggingGetStreamSink, loggingGetStreamSink,
userDiscoverySignData, userDiscoverySignData,
userDiscoveryVerifySignature, userDiscoveryVerifySignature,
@ -611,6 +651,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
const TaskConstMeta( const TaskConstMeta(
debugName: "init_flutter_callbacks", debugName: "init_flutter_callbacks",
argNames: [ argNames: [
"callbackId",
"loggingGetStreamSink", "loggingGetStreamSink",
"userDiscoverySignData", "userDiscoverySignData",
"userDiscoveryVerifySignature", "userDiscoveryVerifySignature",

View file

@ -1,8 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'package:camera/camera.dart'; import 'package:camera/camera.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
final int isolateCallbackId = Random().nextInt(0x7FFFFFFF);
class AppEnvironment { class AppEnvironment {
static late String cacheDir; static late String cacheDir;
static late String supportDir; static late String supportDir;

View file

@ -1,9 +1,11 @@
import 'package:twonly/core/bridge/callbacks.dart'; import 'package:twonly/core/bridge/callbacks.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/callbacks/logging.callbacks.dart'; import 'package:twonly/src/callbacks/logging.callbacks.dart';
import 'package:twonly/src/callbacks/user_discovery.callbacks.dart'; import 'package:twonly/src/callbacks/user_discovery.callbacks.dart';
Future<void> initFlutterCallbacksForRust() async { Future<void> initFlutterCallbacksForRust() async {
await initFlutterCallbacks( await initFlutterCallbacks(
callbackId: isolateCallbackId,
loggingGetStreamSink: LoggingCallbacks.getStreamSink, loggingGetStreamSink: LoggingCallbacks.getStreamSink,
userDiscoverySetShares: UserDiscoveryCallbacks.setShares, userDiscoverySetShares: UserDiscoveryCallbacks.setShares,
userDiscoveryGetShareForContact: userDiscoveryGetShareForContact:

View file

@ -62,5 +62,7 @@ class Routes {
'/settings/developer/automated_testing'; '/settings/developer/automated_testing';
static const String settingsDeveloperReduceFlames = static const String settingsDeveloperReduceFlames =
'/settings/developer/reduce_flames'; '/settings/developer/reduce_flames';
static const String settingsDeveloperInformations =
'/settings/developer/informations';
static const String settingsInvite = '/settings/invite'; static const String settingsInvite = '/settings/invite';
} }

View file

@ -12,4 +12,7 @@ class SecureStorageKeys {
// Not required for backup... // Not required for backup...
static const String receivingPushKeys = 'push_keys_receiving'; static const String receivingPushKeys = 'push_keys_receiving';
static const String sendingPushKeys = 'push_keys_sending'; static const String sendingPushKeys = 'push_keys_sending';
static const String lastFcmMessageTimestamp = 'last_fcm_message_timestamp';
static const String lastServerMessageTimestamp =
'last_server_message_timestamp';
} }

View file

@ -1,3 +1,4 @@
import 'package:clock/clock.dart' show clock;
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:hashlib/random.dart'; import 'package:hashlib/random.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
@ -327,12 +328,16 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
String groupId, String groupId,
DateTime newLastMessage, DateTime newLastMessage,
) async { ) async {
final now = clock.now();
final clampedLastMessage = newLastMessage.isAfter(now)
? now
: newLastMessage;
await (update(groups)..where( await (update(groups)..where(
(t) => (t) =>
t.groupId.equals(groupId) & t.groupId.equals(groupId) &
(t.lastMessageExchange.isSmallerThanValue(newLastMessage)), (t.lastMessageExchange.isSmallerThanValue(clampedLastMessage)),
)) ))
.write(GroupsCompanion(lastMessageExchange: Value(newLastMessage))); .write(GroupsCompanion(lastMessageExchange: Value(clampedLastMessage)));
} }
Stream<List<Group>> watchNonDirectGroupsForMember(int contactId) { Stream<List<Group>> watchNonDirectGroupsForMember(int contactId) {

View file

@ -1,6 +1,7 @@
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:twonly/core/bridge/wrapper/user_discovery.dart'; import 'package:twonly/core/bridge/wrapper/user_discovery.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.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';
@ -216,6 +217,7 @@ class KeyVerificationDao extends DatabaseAccessor<TwonlyDB>
); );
if (userService.currentUser.isUserDiscoveryEnabled) { if (userService.currentUser.isUserDiscoveryEnabled) {
await FlutterUserDiscovery.updateVerificationStateForUser( await FlutterUserDiscovery.updateVerificationStateForUser(
callbackId: isolateCallbackId,
contactId: contactId, contactId: contactId,
publicKeyVerifiedTimestamp: clock.now().millisecondsSinceEpoch, publicKeyVerifiedTimestamp: clock.now().millisecondsSinceEpoch,
); );
@ -232,6 +234,7 @@ class KeyVerificationDao extends DatabaseAccessor<TwonlyDB>
)..where((kv) => kv.contactId.equals(contactId))).go(); )..where((kv) => kv.contactId.equals(contactId))).go();
if (userService.currentUser.isUserDiscoveryEnabled) { if (userService.currentUser.isUserDiscoveryEnabled) {
await FlutterUserDiscovery.updateVerificationStateForUser( await FlutterUserDiscovery.updateVerificationStateForUser(
callbackId: isolateCallbackId,
contactId: contactId, contactId: contactId,
); );
} }
@ -251,6 +254,7 @@ class KeyVerificationDao extends DatabaseAccessor<TwonlyDB>
final remaining = await getContactVerification(contactId); final remaining = await getContactVerification(contactId);
if (remaining.isEmpty && userService.currentUser.isUserDiscoveryEnabled) { if (remaining.isEmpty && userService.currentUser.isUserDiscoveryEnabled) {
await FlutterUserDiscovery.updateVerificationStateForUser( await FlutterUserDiscovery.updateVerificationStateForUser(
callbackId: isolateCallbackId,
contactId: contactId, contactId: contactId,
); );
} }

View file

@ -141,7 +141,9 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
Stream<List<MediaFile>> watchAllStoredMediaFiles() { Stream<List<MediaFile>> watchAllStoredMediaFiles() {
final query = final query =
(select(mediaFiles)..where((t) => t.stored.equals(true))).join([]) (select(mediaFiles)..where((t) => t.stored.equals(true))).join([])
..groupBy([mediaFiles.storedFileHash]); ..groupBy([
const CustomExpression<Object>('COALESCE(stored_file_hash, media_id)')
]);
return query.map((row) => row.readTable(mediaFiles)).watch(); return query.map((row) => row.readTable(mediaFiles)).watch();
} }

View file

@ -46,21 +46,23 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
Stream<List<Message>> watchMediaNotOpened(String groupId) { Stream<List<Message>> watchMediaNotOpened(String groupId) {
final query = final query =
select(messages).join([ select(messages).join([
leftOuterJoin( leftOuterJoin(
mediaFiles, mediaFiles,
mediaFiles.mediaId.equalsExp(messages.mediaId), mediaFiles.mediaId.equalsExp(messages.mediaId),
), ),
])..where( ])
mediaFiles.downloadState ..where(
.equals(DownloadState.reuploadRequested.name) mediaFiles.downloadState
.not() & .equals(DownloadState.reuploadRequested.name)
mediaFiles.type.equals(MediaType.audio.name).not() & .not() &
messages.openedAt.isNull() & mediaFiles.type.equals(MediaType.audio.name).not() &
messages.groupId.equals(groupId) & messages.openedAt.isNull() &
messages.mediaId.isNotNull() & messages.groupId.equals(groupId) &
messages.senderId.isNotNull() & messages.mediaId.isNotNull() &
messages.type.equals(MessageType.media.name), messages.senderId.isNotNull() &
); messages.type.equals(MessageType.media.name),
)
..orderBy([OrderingTerm.asc(messages.createdAt)]);
return query.map((row) => row.readTable(messages)).watch(); return query.map((row) => row.readTable(messages)).watch();
} }
@ -93,24 +95,33 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
milliseconds: group!.deleteMessagesAfterMilliseconds, milliseconds: group!.deleteMessagesAfterMilliseconds,
), ),
); );
return ((select(messages)..where( final query =
(t) => select(messages).join([
t.groupId.equals(groupId) & leftOuterJoin(
// messages in groups will only be removed in case all members have received it... mediaFiles,
// so ensuring that this message is not shown in the messages anymore mediaFiles.mediaId.equalsExp(messages.mediaId),
(t.openedAt.isBiggerThanValue(deletionTime) | ),
t.openedAt.isNull() | ])
t.mediaStored.equals(true)) & ..where(
(t.isDeletedFromSender.equals(true) | messages.groupId.equals(groupId) &
(t.type.equals(MessageType.text.name).not() & (messages.openedAt.isBiggerThanValue(deletionTime) |
t.type.equals(MessageType.media.name).not()) | messages.openedAt.isNull() |
(t.type.equals(MessageType.text.name) & messages.mediaStored.equals(true)) &
t.content.isNotNull()) | (messages.isDeletedFromSender.equals(true) |
(t.type.equals(MessageType.media.name) & (messages.type.equals(MessageType.text.name).not() &
t.mediaId.isNotNull())), messages.type.equals(MessageType.media.name).not()) |
)) (messages.type.equals(MessageType.text.name) &
..orderBy([(t) => OrderingTerm.asc(t.createdAt)])) messages.content.isNotNull()) |
.watch(); (messages.type.equals(MessageType.media.name) &
messages.mediaId.isNotNull() &
(mediaFiles.downloadState.isNull() |
mediaFiles.downloadState
.equals(DownloadState.reuploadRequested.name)
.not()))),
)
..orderBy([OrderingTerm.asc(messages.createdAt)]);
return query.map((row) => row.readTable(messages)).watch();
} }
Stream<List<(GroupMember, Contact)>> watchMembersByGroupId(String groupId) { Stream<List<(GroupMember, Contact)>> watchMembersByGroupId(String groupId) {
@ -161,7 +172,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
m.isDeletedFromSender.equals(true)) | m.isDeletedFromSender.equals(true)) |
m.mediaStored.equals(false)) & m.mediaStored.equals(false)) &
// 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.. // 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.openedByAll.isSmallerThanValue(deletionTime) | ((m.openedByAll.isNotNull() &
m.openedByAll.isSmallerThanValue(deletionTime)) |
(m.isDeletedFromSender.equals(true) & (m.isDeletedFromSender.equals(true) &
m.createdAt.isSmallerThanValue(deletionTime))), m.createdAt.isSmallerThanValue(deletionTime))),
)) ))

View file

@ -239,8 +239,11 @@ class TwonlyDB extends _$TwonlyDB {
} }
void markUpdated() { void markUpdated() {
notifyUpdates({TableUpdate.onTable(messages, kind: UpdateKind.update)}); notifyUpdates({
notifyUpdates({TableUpdate.onTable(contacts, kind: UpdateKind.update)}); TableUpdate.onTable(messages, kind: UpdateKind.update),
TableUpdate.onTable(contacts, kind: UpdateKind.update),
TableUpdate.onTable(groups, kind: UpdateKind.update),
});
} }
Future<void> printTableSizes() async { Future<void> printTableSizes() async {

View file

@ -101,7 +101,7 @@ abstract class AppLocalizations {
/// No description provided for @registerSlogan. /// No description provided for @registerSlogan.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Stay in touch with friends privately and securely.'** /// **'Stay in touch privately.'**
String get registerSlogan; String get registerSlogan;
/// No description provided for @onboardingWelcomeTitle. /// No description provided for @onboardingWelcomeTitle.
@ -173,7 +173,7 @@ abstract class AppLocalizations {
/// No description provided for @registerUsernameSlogan. /// No description provided for @registerUsernameSlogan.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Your public username'** /// **'Create your account'**
String get registerUsernameSlogan; String get registerUsernameSlogan;
/// No description provided for @registerUsernameDecoration. /// No description provided for @registerUsernameDecoration.
@ -1205,8 +1205,8 @@ abstract class AppLocalizations {
/// No description provided for @userFoundBody. /// No description provided for @userFoundBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Do you want to create a follow request?'** /// **'Do you want to connect with {username}?'**
String get userFoundBody; String userFoundBody(String username);
/// No description provided for @errorInternalError. /// No description provided for @errorInternalError.
/// ///
@ -3689,6 +3689,48 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'{count, plural, =1{Import 1 item} other{Import {count} items}}'** /// **'{count, plural, =1{Import 1 item} other{Import {count} items}}'**
String importGalleryImportCount(num count); String importGalleryImportCount(num count);
/// No description provided for @emptyChatListTitle.
///
/// In en, this message translates to:
/// **'Find your first friend'**
String get emptyChatListTitle;
/// No description provided for @emptyChatListDesc.
///
/// In en, this message translates to:
/// **'Let friends scan your QR code, or share them your profile.'**
String get emptyChatListDesc;
/// No description provided for @emptyChatListShareBtn.
///
/// In en, this message translates to:
/// **'Share your profile'**
String get emptyChatListShareBtn;
/// No description provided for @emptyChatListScanBtn.
///
/// In en, this message translates to:
/// **'QR Code'**
String get emptyChatListScanBtn;
/// No description provided for @emptyChatListAddUsernameBtn.
///
/// In en, this message translates to:
/// **'By Username'**
String get emptyChatListAddUsernameBtn;
/// No description provided for @avatarCustomizeRandomize.
///
/// In en, this message translates to:
/// **'Randomize'**
String get avatarCustomizeRandomize;
/// No description provided for @avatarCustomizeReset.
///
/// In en, this message translates to:
/// **'Reset'**
String get avatarCustomizeReset;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -9,8 +9,7 @@ class AppLocalizationsDe extends AppLocalizations {
AppLocalizationsDe([String locale = 'de']) : super(locale); AppLocalizationsDe([String locale = 'de']) : super(locale);
@override @override
String get registerSlogan => String get registerSlogan => 'Privat in Kontakt bleiben.';
'Privat und sicher mit Freunden in Kontakt bleiben.';
@override @override
String get onboardingWelcomeTitle => 'Willkommen bei twonly!'; String get onboardingWelcomeTitle => 'Willkommen bei twonly!';
@ -52,7 +51,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get onboardingGetStartedTitle => 'Auf geht\'s'; String get onboardingGetStartedTitle => 'Auf geht\'s';
@override @override
String get registerUsernameSlogan => 'Dein öffentlicher Benutzername'; String get registerUsernameSlogan => 'Konto erstellen';
@override @override
String get registerUsernameDecoration => 'Benutzername'; String get registerUsernameDecoration => 'Benutzername';
@ -611,7 +610,9 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get userFoundBody => 'Möchtest du eine Folgeanfrage stellen?'; String userFoundBody(String username) {
return 'Möchtest du dich mit $username vernetzen?';
}
@override @override
String get errorInternalError => String get errorInternalError =>
@ -2127,4 +2128,26 @@ class AppLocalizationsDe extends AppLocalizations {
); );
return '$_temp0'; return '$_temp0';
} }
@override
String get emptyChatListTitle => 'Finde deinen ersten Freund';
@override
String get emptyChatListDesc =>
'Lass Freunde deinen QR-Code scannen oder teile dein Profil mit ihnen.';
@override
String get emptyChatListShareBtn => 'Profil teilen';
@override
String get emptyChatListScanBtn => 'QR-Code';
@override
String get emptyChatListAddUsernameBtn => 'Per Benutzername';
@override
String get avatarCustomizeRandomize => 'Zufällig';
@override
String get avatarCustomizeReset => 'Zurücksetzen';
} }

View file

@ -9,8 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale); AppLocalizationsEn([String locale = 'en']) : super(locale);
@override @override
String get registerSlogan => String get registerSlogan => 'Stay in touch privately.';
'Stay in touch with friends privately and securely.';
@override @override
String get onboardingWelcomeTitle => 'Welcome to twonly!'; String get onboardingWelcomeTitle => 'Welcome to twonly!';
@ -51,7 +50,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get onboardingGetStartedTitle => 'Let\'s go!'; String get onboardingGetStartedTitle => 'Let\'s go!';
@override @override
String get registerUsernameSlogan => 'Your public username'; String get registerUsernameSlogan => 'Create your account';
@override @override
String get registerUsernameDecoration => 'Username'; String get registerUsernameDecoration => 'Username';
@ -606,7 +605,9 @@ class AppLocalizationsEn extends AppLocalizations {
} }
@override @override
String get userFoundBody => 'Do you want to create a follow request?'; String userFoundBody(String username) {
return 'Do you want to connect with $username?';
}
@override @override
String get errorInternalError => String get errorInternalError =>
@ -2109,4 +2110,26 @@ class AppLocalizationsEn extends AppLocalizations {
); );
return '$_temp0'; return '$_temp0';
} }
@override
String get emptyChatListTitle => 'Find your first friend';
@override
String get emptyChatListDesc =>
'Let friends scan your QR code, or share them your profile.';
@override
String get emptyChatListShareBtn => 'Share your profile';
@override
String get emptyChatListScanBtn => 'QR Code';
@override
String get emptyChatListAddUsernameBtn => 'By Username';
@override
String get avatarCustomizeRandomize => 'Randomize';
@override
String get avatarCustomizeReset => 'Reset';
} }

@ -1 +1 @@
Subproject commit 189bf8f4dbe2bee4f19a15b9640b8826e4f2e235 Subproject commit c95e98ca929d630ead028d84e13934b30dbeba3b

View file

@ -114,8 +114,8 @@ class UserData {
@JsonKey(defaultValue: 4) @JsonKey(defaultValue: 4)
int requiredSendImages = 4; int requiredSendImages = 4;
@JsonKey(defaultValue: 2) @JsonKey(defaultValue: 3)
int userDiscoveryThreshold = 2; int userDiscoveryThreshold = 3;
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
bool userDiscoveryRequiresManualApproval = false; bool userDiscoveryRequiresManualApproval = false;

View file

@ -62,7 +62,7 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
), ),
) )
..storeMediaFilesInGallery = ..storeMediaFilesInGallery =
json['storeMediaFilesInGallery'] as bool? ?? false json['storeMediaFilesInGallery'] as bool? ?? true
..autoStoreAllSendUnlimitedMediaFiles = ..autoStoreAllSendUnlimitedMediaFiles =
json['autoStoreAllSendUnlimitedMediaFiles'] as bool? ?? false json['autoStoreAllSendUnlimitedMediaFiles'] as bool? ?? false
..typingIndicators = json['typingIndicators'] as bool? ?? true ..typingIndicators = json['typingIndicators'] as bool? ?? true
@ -78,7 +78,7 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
json['isUserDiscoveryEnabled'] as bool? ?? false json['isUserDiscoveryEnabled'] as bool? ?? false
..requiredSendImages = (json['requiredSendImages'] as num?)?.toInt() ?? 4 ..requiredSendImages = (json['requiredSendImages'] as num?)?.toInt() ?? 4
..userDiscoveryThreshold = ..userDiscoveryThreshold =
(json['userDiscoveryThreshold'] as num?)?.toInt() ?? 2 (json['userDiscoveryThreshold'] as num?)?.toInt() ?? 3
..userDiscoveryRequiresManualApproval = ..userDiscoveryRequiresManualApproval =
json['userDiscoveryRequiresManualApproval'] as bool? ?? false json['userDiscoveryRequiresManualApproval'] as bool? ?? false
..userDiscoverySharePromotion = ..userDiscoverySharePromotion =

View file

@ -26,6 +26,7 @@ import 'package:twonly/src/visual/views/settings/data_and_storage/import_from_ga
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';
import 'package:twonly/src/visual/views/settings/developer/informations.view.dart';
import 'package:twonly/src/visual/views/settings/developer/reduce_flames.view.dart'; import 'package:twonly/src/visual/views/settings/developer/reduce_flames.view.dart';
import 'package:twonly/src/visual/views/settings/developer/retransmission_data.view.dart'; import 'package:twonly/src/visual/views/settings/developer/retransmission_data.view.dart';
import 'package:twonly/src/visual/views/settings/help/changelog.view.dart'; import 'package:twonly/src/visual/views/settings/help/changelog.view.dart';
@ -288,6 +289,10 @@ final routerProvider = GoRouter(
path: 'automated_testing', path: 'automated_testing',
builder: (context, state) => const AutomatedTestingView(), builder: (context, state) => const AutomatedTestingView(),
), ),
GoRoute(
path: 'informations',
builder: (context, state) => const DeveloperInformationsView(),
),
GoRoute( GoRoute(
path: 'reduce_flames', path: 'reduce_flames',
builder: (context, state) => const ReduceFlamesView(), builder: (context, state) => const ReduceFlamesView(),

View file

@ -252,7 +252,7 @@ Future<void> downloadFileFast(
} else { } else {
if (response.statusCode == 404 || response.statusCode == 403) { if (response.statusCode == 404 || response.statusCode == 403) {
Log.error( Log.error(
'Got ${response.statusCode} from server. Requesting upload again', 'Got ${response.statusCode} from server for media ID ${media.mediaId}. Requesting upload again',
); );
// Message was deleted from the server. Requesting it again from the sender to upload it again... // Message was deleted from the server. Requesting it again from the sender to upload it again...
await requestMediaReupload(media.mediaId); await requestMediaReupload(media.mediaId);

View file

@ -31,6 +31,7 @@ import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/services/group.service.dart'; import 'package:twonly/src/services/group.service.dart';
import 'package:twonly/src/services/key_verification.service.dart'; import 'package:twonly/src/services/key_verification.service.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/signal/encryption.signal.dart'; import 'package:twonly/src/services/signal/encryption.signal.dart';
import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/services/signal/session.signal.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
@ -153,7 +154,10 @@ Future<void> _handleClient2ClientMessage(
Log.info( Log.info(
'[$receiptId] Sending error message to the original sender with receiptId $newReceiptId.', '[$receiptId] Sending error message to the original sender with receiptId $newReceiptId.',
); );
await tryToSendCompleteMessage(receiptId: newReceiptId, blocking: false); await tryToSendCompleteMessage(
receiptId: newReceiptId,
blocking: false,
);
} }
case Message_Type.CIPHERTEXT: case Message_Type.CIPHERTEXT:
@ -276,9 +280,15 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessageRaw(
Log.info('[$receiptId] Finished handleEncryptedMessage'); Log.info('[$receiptId] Finished handleEncryptedMessage');
if (Platform.isAndroid && a == null && b == null) { if (a == null && b == null) {
// Message was handled without any error -> Show push notification to the user. unawaited(updateLastServerMessageTimestamp());
await showPushNotificationFromServerMessages(fromUserId, encryptedContent); if (Platform.isAndroid) {
// Message was handled without any error. Show push notification to the user for Android.
await showPushNotificationFromServerMessages(
fromUserId,
encryptedContent,
);
}
} }
return (a, b); return (a, b);

View file

@ -213,7 +213,12 @@ class MediaFileService {
} }
Future<void> createThumbnail() async { Future<void> createThumbnail() async {
if (!storedPath.existsSync()) { if (!storedPath.existsSync() || storedPath.lengthSync() == 0) {
if (storedPath.existsSync() && storedPath.lengthSync() == 0) {
try {
storedPath.deleteSync();
} catch (_) {}
}
if (mediaFile.stored && if (mediaFile.stored &&
mediaFile.createdAt.isBefore( mediaFile.createdAt.isBefore(
clock.now().subtract(const Duration(days: 30)), clock.now().subtract(const Duration(days: 30)),
@ -288,8 +293,10 @@ class MediaFileService {
bool get imagePreviewAvailable => bool get imagePreviewAvailable =>
mediaFile.hasThumbnail || mediaFile.hasThumbnail ||
thumbnailPath.existsSync() || (thumbnailPath.existsSync() && thumbnailPath.lengthSync() > 0) ||
storedPath.existsSync(); mediaFile.type == MediaType.audio ||
((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) &&
storedPath.existsSync() && storedPath.lengthSync() > 0);
Future<void> storeMediaFile() async { Future<void> storeMediaFile() async {
Log.info('Storing media file ${mediaFile.mediaId}'); Log.info('Storing media file ${mediaFile.mediaId}');
@ -439,7 +446,7 @@ class MediaFileService {
return; return;
} }
if (!storedPath.existsSync()) { if (!storedPath.existsSync() || storedPath.lengthSync() == 0) {
await twonlyDB.mediaFilesDao.updateMedia( await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId, mediaFile.mediaId,
const MediaFilesCompanion(hasCropAnalyzed: Value(true)), const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
@ -448,7 +455,7 @@ class MediaFileService {
} }
try { try {
final bytes = storedPath.readAsBytesSync(); final bytes = await storedPath.readAsBytes();
final result = await compute(_processImageCrop, bytes); final result = await compute(_processImageCrop, bytes);
if (result.isCropped && result.pngBytes != null) { if (result.isCropped && result.pngBytes != null) {
@ -460,18 +467,18 @@ class MediaFileService {
); );
if (webpBytes.isNotEmpty) { if (webpBytes.isNotEmpty) {
storedPath.writeAsBytesSync(webpBytes); await storedPath.writeAsBytes(webpBytes);
} else { } else {
Log.warn('WebP compression returned empty, falling back to PNG'); Log.warn('WebP compression returned empty, falling back to PNG');
storedPath.writeAsBytesSync(result.pngBytes!); await storedPath.writeAsBytes(result.pngBytes!);
} }
} catch (e) { } catch (e) {
Log.error('Error compressing to WebP, falling back to PNG: $e'); Log.error('Error compressing to WebP, falling back to PNG: $e');
storedPath.writeAsBytesSync(result.pngBytes!); await storedPath.writeAsBytes(result.pngBytes!);
} }
if (thumbnailPath.existsSync()) { if (thumbnailPath.existsSync()) {
thumbnailPath.deleteSync(); await thumbnailPath.delete();
} }
await createThumbnail(); await createThumbnail();
final checksum = await sha256File(storedPath); final checksum = await sha256File(storedPath);

View file

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui'; import 'dart:ui';
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:pro_video_editor/pro_video_editor.dart'; import 'package:pro_video_editor/pro_video_editor.dart';
@ -11,34 +12,61 @@ Future<bool> createThumbnailsForVideo(
) async { ) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
if (destinationFile.existsSync()) { if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) {
return true; Log.warn('Source video file does not exist or is empty.');
} try {
if (destinationFile.existsSync()) {
final images = await ProVideoEditor.instance.getThumbnails( destinationFile.deleteSync();
ThumbnailConfigs( }
video: EditorVideo.file(sourceFile), } catch (_) {}
outputFormat: ThumbnailFormat.webp,
timestamps: const [
Duration.zero,
],
outputSize: const Size(272, 153),
),
);
if (images.isNotEmpty) {
stopwatch.stop();
destinationFile.writeAsBytesSync(images.first);
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the video thumbnail.',
);
return true;
} else {
Log.warn(
'Thumbnail creation failed for the video.',
);
return false; return false;
} }
if (destinationFile.existsSync()) {
if (destinationFile.lengthSync() > 0) {
return true;
} else {
try {
destinationFile.deleteSync();
} catch (_) {}
}
}
try {
final images = await ProVideoEditor.instance.getThumbnails(
ThumbnailConfigs(
video: EditorVideo.file(sourceFile),
outputFormat: ThumbnailFormat.webp,
timestamps: const [
Duration.zero,
],
outputSize: const Size(272, 153),
),
);
if (images.isNotEmpty && images.first.isNotEmpty) {
stopwatch.stop();
await destinationFile.writeAsBytes(images.first);
if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) {
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the video thumbnail.',
);
return true;
}
}
} catch (e) {
Log.error('Error creating video thumbnail: $e');
}
Log.warn(
'Thumbnail creation failed for the video.',
);
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
} }
Future<bool> createThumbnailsForImage( Future<bool> createThumbnailsForImage(
@ -47,6 +75,26 @@ Future<bool> createThumbnailsForImage(
) async { ) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) {
Log.warn('Source image file does not exist or is empty.');
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
if (destinationFile.existsSync()) {
if (destinationFile.lengthSync() > 0) {
return true;
} else {
try {
destinationFile.deleteSync();
} catch (_) {}
}
}
try { try {
await FlutterImageCompress.compressAndGetFile( await FlutterImageCompress.compressAndGetFile(
sourceFile.absolute.path, sourceFile.absolute.path,
@ -57,12 +105,28 @@ Future<bool> createThumbnailsForImage(
format: CompressFormat.webp, format: CompressFormat.webp,
); );
stopwatch.stop(); stopwatch.stop();
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the image thumbnail.', if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) {
); Log.info(
return true; 'It took ${stopwatch.elapsedMilliseconds}ms to create the image thumbnail.',
);
return true;
} else {
Log.error('Compressed image thumbnail is empty or missing.');
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
} catch (e) { } catch (e) {
Log.error('Error creating image thumbnail: $e'); Log.error('Error creating image thumbnail: $e');
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false; return false;
} }
} }
@ -73,40 +137,81 @@ Future<bool> createThumbnailsForGif(
) async { ) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) {
Log.warn('Source GIF file does not exist or is empty.');
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
if (destinationFile.existsSync()) { if (destinationFile.existsSync()) {
return true; if (destinationFile.lengthSync() > 0) {
return true;
} else {
try {
destinationFile.deleteSync();
} catch (_) {}
}
} }
try { try {
// For GIFs, we decode the first frame and save it as WebP // For GIFs, we decode the first frame and save it as WebP
final bytes = sourceFile.readAsBytesSync(); final bytes = await sourceFile.readAsBytes();
final image = img.decodeGif(bytes); final pngBytes = await compute(_processGifThumbnail, bytes);
if (image == null) { if (pngBytes == null || pngBytes.isEmpty) {
Log.error('Could not decode GIF for thumbnail.'); Log.error('Could not decode GIF for thumbnail.');
return false; return false;
} }
final thumbnail = img.copyResize(
image,
width: image.width > image.height ? 400 : null,
height: image.height >= image.width ? 400 : null,
);
final pngBytes = img.encodePng(thumbnail);
final webp = await FlutterImageCompress.compressWithList( final webp = await FlutterImageCompress.compressWithList(
pngBytes, pngBytes,
format: CompressFormat.webp, format: CompressFormat.webp,
quality: 85, quality: 85,
); );
destinationFile.writeAsBytesSync(webp); if (webp.isEmpty) {
Log.error('GIF thumbnail compression returned empty.');
return false;
}
await destinationFile.writeAsBytes(webp);
stopwatch.stop(); stopwatch.stop();
Log.info( if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) {
'It took ${stopwatch.elapsedMilliseconds}ms to create the GIF thumbnail.', Log.info(
); 'It took ${stopwatch.elapsedMilliseconds}ms to create the GIF thumbnail.',
return true; );
return true;
} else {
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
} catch (e) { } catch (e) {
Log.error('Error creating GIF thumbnail: $e'); Log.error('Error creating GIF thumbnail: $e');
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false; return false;
} }
} }
Uint8List? _processGifThumbnail(Uint8List bytes) {
final image = img.decodeGif(bytes);
if (image == null) return null;
final thumbnail = img.copyResize(
image,
width: image.width > image.height ? 400 : null,
height: image.height >= image.width ? 400 : null,
);
return img.encodePng(thumbnail);
}

View file

@ -200,6 +200,12 @@ class MemoriesService {
Future<void> _initAsync() async { Future<void> _initAsync() async {
try { try {
// Start DB subscription first so files with existing thumbnails are shown immediately.
await _dbSubscription?.cancel();
_dbSubscription = twonlyDB.mediaFilesDao
.watchAllStoredMediaFiles()
.listen(_processMediaFilesStream);
final pendingFiles = await twonlyDB.mediaFilesDao final pendingFiles = await twonlyDB.mediaFilesDao
.getAllMediaFilesPendingMigration(); .getAllMediaFilesPendingMigration();
@ -210,23 +216,25 @@ class MemoriesService {
); );
_notifyState(); _notifyState();
for (final mediaFile in pendingFiles) { // Run the multi-step background migration process asynchronously.
unawaited(_processMigrationQueue(pendingFiles));
}
} catch (e) {
Log.error('Error initializing MemoriesService: $e');
}
}
Future<void> _processMigrationQueue(List<MediaFile> pendingFiles) async {
try {
// Phase 1: Create thumbnails first so files can be shown in the
// gallery immediately, without waiting for heavier operations.
for (final mediaFile in pendingFiles) {
try {
final mediaService = MediaFileService(mediaFile); final mediaService = MediaFileService(mediaFile);
if (mediaService.mediaFile.storedFileHash == null) {
await mediaService.hashMediaFile();
}
if (!mediaService.mediaFile.hasCropAnalyzed) {
await mediaService.cropTransparentBorders();
}
if (mediaService.mediaFile.sizeInBytes == null) {
await mediaService.calculateAndSaveSize();
}
if (!mediaService.mediaFile.hasThumbnail) { if (!mediaService.mediaFile.hasThumbnail) {
if (mediaService.thumbnailPath.existsSync()) { if (mediaService.thumbnailPath.existsSync() &&
mediaService.thumbnailPath.lengthSync() > 0) {
await twonlyDB.mediaFilesDao.updateMedia( await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId, mediaFile.mediaId,
const MediaFilesCompanion(hasThumbnail: Value(true)), const MediaFilesCompanion(hasThumbnail: Value(true)),
@ -235,18 +243,48 @@ class MemoriesService {
await mediaService.createThumbnail(); await mediaService.createThumbnail();
} }
} }
_updateMigrationCount(_currentState.filesToMigrate - 1); } catch (e) {
Log.error(
'Error creating thumbnail for ${mediaFile.mediaId}: $e',
);
} }
_updateMigrationCount(_currentState.filesToMigrate - 1);
_updateMigrationCount(0);
} }
await _dbSubscription?.cancel(); _updateMigrationCount(0);
_dbSubscription = twonlyDB.mediaFilesDao
.watchAllStoredMediaFiles() // Phase 2: Background hash, crop analysis, size calculation.
.listen(_processMediaFilesStream); // Each DB write here fires the stream subscription above, keeping
// the gallery state fresh without a separate notification step.
await _backgroundProcessPendingFiles(pendingFiles);
} catch (e) { } catch (e) {
Log.error('Error initializing MemoriesService: $e'); Log.error('Error in background migration queue: $e');
}
}
Future<void> _backgroundProcessPendingFiles(
List<MediaFile> pendingFiles,
) async {
for (final mediaFile in pendingFiles) {
try {
final mediaService = MediaFileService(mediaFile);
if (mediaService.mediaFile.storedFileHash == null) {
await mediaService.hashMediaFile();
}
if (!mediaService.mediaFile.hasCropAnalyzed) {
await mediaService.cropTransparentBorders();
}
if (mediaService.mediaFile.sizeInBytes == null) {
await mediaService.calculateAndSaveSize();
}
} catch (e) {
Log.error(
'Error in background processing of ${mediaFile.mediaId}: $e',
);
}
} }
} }

View file

@ -14,6 +14,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/json/signal_identity.model.dart'; import 'package:twonly/src/model/json/signal_identity.model.dart';
import 'package:twonly/src/services/api/mediafiles/download.api.dart'; import 'package:twonly/src/services/api/mediafiles/download.api.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/services/user_discovery.service.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/secure_storage.dart'; import 'package:twonly/src/utils/secure_storage.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
@ -144,6 +145,19 @@ Future<void> runMigrations() async {
} }
} }
if (userService.currentUser.appVersion < 116) {
if (userService.currentUser.userDiscoveryThreshold == 2) {
if (userService.currentUser.isUserDiscoveryEnabled) {
await UserDiscoveryService.initializeOrUpdate(
threshold: 3,
sharePromotion: userService.currentUser.userDiscoverySharePromotion,
);
} else {
await UserService.update((u) => u..userDiscoveryThreshold = 3);
}
}
await UserService.update((u) => u.appVersion = 116);
}
if (kDebugMode) { if (kDebugMode) {
assert( assert(
AppState.latestAppVersionId == 116, AppState.latestAppVersionId == 116,

View file

@ -6,9 +6,11 @@ import 'dart:io' show Platform;
import 'package:firebase_app_installations/firebase_app_installations.dart'; import 'package:firebase_app_installations/firebase_app_installations.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/services/background/callback_dispatcher.background.dart'; import 'package:twonly/src/services/background/callback_dispatcher.background.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart'; import 'package:twonly/src/services/notifications/setup.notifications.dart';
@ -107,6 +109,7 @@ Future<void> initFCMService() async {
); );
unawaited(checkForTokenUpdates()); unawaited(checkForTokenUpdates());
unawaited(checkFcmHealthAndResetIfNeeded());
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
@ -133,6 +136,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
} }
Future<void> handleRemoteMessage(RemoteMessage message) async { Future<void> handleRemoteMessage(RemoteMessage message) async {
await updateLastFcmMessageTimestamp();
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
Log.error('Got message in Dart while on iOS'); Log.error('Got message in Dart while on iOS');
} }
@ -157,3 +161,100 @@ Future<void> handleRemoteMessage(RemoteMessage message) async {
// await handlePushData(message.data['push_data'] as String); // await handlePushData(message.data['push_data'] as String);
// } // }
} }
Future<void> updateLastFcmMessageTimestamp() async {
const storage = FlutterSecureStorage();
final nowMs = DateTime.now().millisecondsSinceEpoch.toString();
try {
await storage.write(
key: SecureStorageKeys.lastFcmMessageTimestamp,
value: nowMs,
iOptions: const IOSOptions(
groupId: 'CN332ZUGRP.eu.twonly.shared',
accessibility: KeychainAccessibility.first_unlock,
),
);
Log.info('Updated last FCM message timestamp to $nowMs');
} catch (e) {
Log.error('Could not write last FCM message timestamp: $e');
}
}
Future<void> updateLastServerMessageTimestamp() async {
const storage = FlutterSecureStorage();
final nowMs = DateTime.now().millisecondsSinceEpoch.toString();
try {
await storage.write(
key: SecureStorageKeys.lastServerMessageTimestamp,
value: nowMs,
iOptions: const IOSOptions(
groupId: 'CN332ZUGRP.eu.twonly.shared',
accessibility: KeychainAccessibility.first_unlock,
),
);
Log.info('Updated last server message timestamp to $nowMs');
} catch (e) {
Log.error('Could not write last server message timestamp: $e');
}
}
Future<void> checkFcmHealthAndResetIfNeeded() async {
if (!userService.isUserCreated) return;
const storage = FlutterSecureStorage();
try {
final lastFcmStr = await storage.read(
key: SecureStorageKeys.lastFcmMessageTimestamp,
iOptions: const IOSOptions(
groupId: 'CN332ZUGRP.eu.twonly.shared',
accessibility: KeychainAccessibility.first_unlock,
),
);
final lastServerStr = await storage.read(
key: SecureStorageKeys.lastServerMessageTimestamp,
iOptions: const IOSOptions(
groupId: 'CN332ZUGRP.eu.twonly.shared',
accessibility: KeychainAccessibility.first_unlock,
),
);
final now = DateTime.now();
final threeDaysAgo = now.subtract(const Duration(days: 3));
DateTime? lastFcmTime;
if (lastFcmStr != null) {
final ms = int.tryParse(lastFcmStr);
if (ms != null) {
lastFcmTime = DateTime.fromMillisecondsSinceEpoch(ms);
}
}
if (lastFcmTime != null) {
Log.info('Last message received via FCM messaging system: $lastFcmTime');
} else {
Log.info('No record of a message received via FCM messaging system.');
}
DateTime? lastServerTime;
if (lastServerStr != null) {
final ms = int.tryParse(lastServerStr);
if (ms != null) {
lastServerTime = DateTime.fromMillisecondsSinceEpoch(ms);
}
}
// Check conditions:
// 1. No messages received via FCM in the last 3 days (either null or older than 3 days)
final fcmInactive = lastFcmTime == null || lastFcmTime.isBefore(threeDaysAgo);
// 2. Server message received within the last 3 days
final serverActive = lastServerTime != null && lastServerTime.isAfter(threeDaysAgo);
if (fcmInactive && serverActive) {
Log.warn('FCM has been inactive for >3 days, but server messages have been active. Resetting FCM tokens...');
await resetFCMTokens();
} else {
Log.info('FCM check passed. No reset needed.');
}
} catch (e) {
Log.error('Error during FCM health check: $e');
}
}

View file

@ -84,6 +84,7 @@ class UserDiscoveryService {
final publicKey = await getUserPublicKey(); final publicKey = await getUserPublicKey();
Log.info('UserDiscoveryService: initializing Rust bridge'); Log.info('UserDiscoveryService: initializing Rust bridge');
await FlutterUserDiscovery.initializeOrUpdate( await FlutterUserDiscovery.initializeOrUpdate(
callbackId: isolateCallbackId,
threshold: threshold, threshold: threshold,
userId: userId, userId: userId,
publicKey: publicKey, publicKey: publicKey,
@ -104,7 +105,7 @@ class UserDiscoveryService {
static Future<Uint8List?> getCurrentVersion() async { static Future<Uint8List?> getCurrentVersion() async {
try { try {
return await FlutterUserDiscovery.getCurrentVersion() return await FlutterUserDiscovery.getCurrentVersion(callbackId: isolateCallbackId)
.timeout(const Duration(seconds: 5)); .timeout(const Duration(seconds: 5));
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);
@ -139,6 +140,7 @@ class UserDiscoveryService {
) async { ) async {
try { try {
return await FlutterUserDiscovery.shouldRequestNewMessages( return await FlutterUserDiscovery.shouldRequestNewMessages(
callbackId: isolateCallbackId,
contactId: fromUserId, contactId: fromUserId,
version: receivedVersion, version: receivedVersion,
).timeout(const Duration(seconds: 5)); ).timeout(const Duration(seconds: 5));
@ -154,6 +156,7 @@ class UserDiscoveryService {
) async { ) async {
try { try {
return await FlutterUserDiscovery.getNewMessages( return await FlutterUserDiscovery.getNewMessages(
callbackId: isolateCallbackId,
contactId: fromUserId, contactId: fromUserId,
receivedVersion: receivedVersion, receivedVersion: receivedVersion,
).timeout(const Duration(seconds: 5)); ).timeout(const Duration(seconds: 5));
@ -172,6 +175,7 @@ class UserDiscoveryService {
.getContactVerification(fromUserId); .getContactVerification(fromUserId);
return await FlutterUserDiscovery.handleNewMessages( return await FlutterUserDiscovery.handleNewMessages(
callbackId: isolateCallbackId,
contactId: fromUserId, contactId: fromUserId,
messages: messages, messages: messages,
publicKeyVerifiedTimestamp: publicKeyVerifiedTimestamp:

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
@ -18,8 +19,62 @@ class ContextMenu extends StatefulWidget {
State<ContextMenu> createState() => _ContextMenuState(); State<ContextMenu> createState() => _ContextMenuState();
} }
class _ContextMenuState extends State<ContextMenu> { class _ContextMenuState extends State<ContextMenu>
with SingleTickerProviderStateMixin {
Offset? _tapPosition; Offset? _tapPosition;
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller =
AnimationController(
vsync: this,
lowerBound: double.negativeInfinity,
upperBound: double.infinity,
value: 0,
)..addListener(() {
setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails details) {
_tapPosition = details.globalPosition;
_controller.animateTo(
1,
duration: const Duration(milliseconds: 60),
curve: Curves.easeOut,
);
}
void _onTapUp(TapUpDetails details) {
_bounce();
}
void _onTapCancel() {
_bounce();
}
void _bounce() {
const spring = SpringDescription(
mass: 1,
stiffness: 400,
damping: 15,
);
final simulation = SpringSimulation(
spring,
_controller.value,
0,
_controller.velocity,
);
_controller.animateWith(simulation);
}
Widget _getIcon(dynamic icon) { Widget _getIcon(dynamic icon) {
return Padding( return Padding(
@ -45,6 +100,7 @@ class _ContextMenuState extends State<ContextMenu> {
return; return;
} }
unawaited(HapticFeedback.heavyImpact()); unawaited(HapticFeedback.heavyImpact());
_bounce();
await showMenu( await showMenu(
context: context, context: context,
@ -82,12 +138,16 @@ class _ContextMenuState extends State<ContextMenu> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scale = 1.0 - (_controller.value * 0.02);
return GestureDetector( return GestureDetector(
onLongPress: _showCustomMenu, onLongPress: _showCustomMenu,
onTapDown: (details) { onTapDown: _onTapDown,
_tapPosition = details.globalPosition; onTapUp: _onTapUp,
}, onTapCancel: _onTapCancel,
child: widget.child, child: Transform.scale(
scale: scale,
child: widget.child,
),
); );
} }
} }

View file

@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/themes/light.dart';
enum MyButtonVariant {
primary,
secondary,
text,
primaryMiddle,
primaryDense,
secondaryDense,
}
class MyButton extends StatefulWidget {
const MyButton({
required this.child,
required this.onPressed,
this.onLongPress,
this.variant = MyButtonVariant.primary,
super.key,
});
final Widget child;
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
final MyButtonVariant variant;
@override
State<MyButton> createState() => _MyButtonState();
}
class _MyButtonState extends State<MyButton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller =
AnimationController(
vsync: this,
lowerBound: double.negativeInfinity,
upperBound: double.infinity,
value: 0,
)..addListener(() {
setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails details) {
if (widget.onPressed != null || widget.onLongPress != null) {
_controller.animateTo(
1,
duration: const Duration(milliseconds: 60),
curve: Curves.easeOut,
);
}
}
void _onTapUp(TapUpDetails details) {
if (widget.onPressed != null || widget.onLongPress != null) {
_bounce();
}
}
void _onTapCancel() {
if (widget.onPressed != null || widget.onLongPress != null) {
_bounce();
}
}
void _bounce() {
const spring = SpringDescription(
mass: 1,
stiffness: 400,
damping: 15,
);
final simulation = SpringSimulation(
spring,
_controller.value,
0,
_controller.velocity,
);
_controller.animateWith(simulation);
}
@override
Widget build(BuildContext context) {
// 0 (unpressed) -> scale 1.0
// 1 (pressed) -> scale 0.98 (subtle bounce)
final scale = 1.0 - (_controller.value * 0.02);
final isEnabled = widget.onPressed != null || widget.onLongPress != null;
final isDark = isDarkMode(context);
final disabledBgColor = isDark
? const Color(0xFF353535)
: const Color(0xFFE0E0E0);
final disabledFgColor = isDark
? const Color(0xFF757575)
: const Color(0xFF9E9E9E);
late final ButtonStyle buttonStyle;
switch (widget.variant) {
case MyButtonVariant.primary:
buttonStyle = FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.black87,
disabledBackgroundColor: disabledBgColor,
disabledForegroundColor: disabledFgColor,
minimumSize: const Size.fromHeight(60),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
elevation: 0,
textStyle: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
);
case MyButtonVariant.secondary:
buttonStyle = FilledButton.styleFrom(
backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200],
foregroundColor: isDark ? Colors.white : Colors.black87,
disabledBackgroundColor: disabledBgColor,
disabledForegroundColor: disabledFgColor,
minimumSize: const Size.fromHeight(60),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
elevation: 0,
textStyle: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
);
case MyButtonVariant.text:
buttonStyle = TextButton.styleFrom(
minimumSize: const Size(0, 50),
foregroundColor: isDark
? Colors.white.withValues(alpha: 0.7)
: Colors.black.withValues(alpha: 0.7),
disabledForegroundColor: disabledFgColor,
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
);
case MyButtonVariant.primaryMiddle:
buttonStyle = FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.black87,
disabledBackgroundColor: disabledBgColor,
disabledForegroundColor: disabledFgColor,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(
horizontal: 24,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
);
case MyButtonVariant.primaryDense:
buttonStyle = FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.black87,
disabledBackgroundColor: disabledBgColor,
disabledForegroundColor: disabledFgColor,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
);
case MyButtonVariant.secondaryDense:
buttonStyle = FilledButton.styleFrom(
backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200],
foregroundColor: isDark ? Colors.white : Colors.black87,
disabledBackgroundColor: disabledBgColor,
disabledForegroundColor: disabledFgColor,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
);
}
final childButton = widget.variant == MyButtonVariant.text
? TextButton(
style: buttonStyle,
onPressed: isEnabled ? () {} : null,
child: widget.child,
)
: FilledButton(
style: buttonStyle,
onPressed: isEnabled ? () {} : null,
child: widget.child,
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: isEnabled ? _onTapDown : null,
onTapUp: isEnabled ? _onTapUp : null,
onTapCancel: isEnabled ? _onTapCancel : null,
onTap: widget.onPressed,
onLongPress: widget.onLongPress,
child: Transform.scale(
scale: scale,
child: AbsorbPointer(
child: childButton,
),
),
);
}
}

View file

@ -0,0 +1,237 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/services.dart';
import 'package:twonly/src/utils/misc.dart';
class MyInput extends StatefulWidget {
const MyInput({
required this.controller,
this.onChanged,
this.onSubmitted,
this.inputFormatters,
this.hintText,
this.prefixIcon,
this.suffixIcon,
this.keyboardType,
this.autofocus = false,
this.errorText,
this.obscureText = false,
super.key,
});
final TextEditingController controller;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final List<TextInputFormatter>? inputFormatters;
final String? hintText;
final Widget? prefixIcon;
final Widget? suffixIcon;
final TextInputType? keyboardType;
final bool autofocus;
final String? errorText;
final bool obscureText;
@override
State<MyInput> createState() => _MyInputState();
}
class _MyInputState extends State<MyInput> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller =
AnimationController(
vsync: this,
lowerBound: double.negativeInfinity,
upperBound: double.infinity,
value: 0,
)..addListener(() {
setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails details) {
_controller.animateTo(
1,
duration: const Duration(milliseconds: 60),
curve: Curves.easeOut,
);
}
void _onTapUp(TapUpDetails details) {
_bounce();
}
void _onTapCancel() {
_bounce();
}
void _bounce() {
const spring = SpringDescription(
mass: 1,
stiffness: 400,
damping: 15,
);
final simulation = SpringSimulation(
spring,
_controller.value,
0,
_controller.velocity,
);
_controller.animateWith(simulation);
}
@override
Widget build(BuildContext context) {
// 0 (unpressed) -> scale 1.0
// 1 (pressed) -> scale 0.98 (subtle bounce)
final scale = 1.0 - (_controller.value * 0.02);
final isDark = isDarkMode(context);
final inputFillColor = isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05);
final inputBorderColor = isDark
? Colors.white.withValues(alpha: 0.15)
: Colors.black.withValues(alpha: 0.15);
final inputHintColor = isDark
? Colors.white.withValues(alpha: 0.5)
: Colors.black.withValues(alpha: 0.5);
final prefixIconColor = isDark
? Colors.white.withValues(alpha: 0.6)
: Colors.black.withValues(alpha: 0.6);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
child: Transform.scale(
scale: scale,
child: TextField(
controller: widget.controller,
onChanged: widget.onChanged,
onSubmitted: widget.onSubmitted,
onTapOutside: (event) {
final pointer = event.pointer;
final startPosition = event.position;
var moved = false;
void handlePointerEvent(PointerEvent routeEvent) {
if (routeEvent is PointerMoveEvent) {
if ((routeEvent.position - startPosition).distance > 10) {
moved = true;
}
} else if (routeEvent is PointerUpEvent) {
GestureBinding.instance.pointerRouter.removeRoute(
pointer,
handlePointerEvent,
);
if (!moved) {
FocusManager.instance.primaryFocus?.unfocus();
}
} else if (routeEvent is PointerCancelEvent) {
GestureBinding.instance.pointerRouter.removeRoute(
pointer,
handlePointerEvent,
);
}
}
GestureBinding.instance.pointerRouter.addRoute(
pointer,
handlePointerEvent,
);
},
inputFormatters: widget.inputFormatters,
keyboardType: widget.keyboardType,
autofocus: widget.autofocus,
obscureText: widget.obscureText,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black87,
),
decoration: InputDecoration(
hintText: widget.hintText,
hintStyle: TextStyle(
color: inputHintColor,
),
filled: true,
fillColor: inputFillColor,
contentPadding: const EdgeInsets.symmetric(
vertical: 18,
horizontal: 24,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: inputBorderColor,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: inputBorderColor,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: BorderSide(
color: isDark ? Colors.white : Colors.black87,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: const BorderSide(
color: Colors.redAccent,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: const BorderSide(
color: Colors.redAccent,
width: 2,
),
),
errorStyle: const TextStyle(
color: Colors.redAccent,
fontSize: 14,
fontWeight: FontWeight.w500,
),
errorText: widget.errorText,
prefixIcon: widget.prefixIcon != null
? IconTheme(
data: IconThemeData(
color: prefixIconColor,
),
child: widget.prefixIcon!,
)
: null,
suffixIcon: widget.suffixIcon != null
? IconTheme(
data: IconThemeData(
color: isDark ? Colors.white : Colors.black87,
),
child: widget.suffixIcon!,
)
: null,
),
),
),
);
}
}

View file

@ -25,10 +25,20 @@ class ScreenshotImageHelper {
return imageBytes; return imageBytes;
} }
if (imageBytesFuture != null) { if (imageBytesFuture != null) {
return imageBytesFuture; try {
return imageBytes = await imageBytesFuture;
} catch (e) {
Log.error('Could not resolve imageBytesFuture: $e');
return null;
}
} }
if (file != null) { if (file != null) {
return file!.readAsBytes(); try {
return imageBytes = await file!.readAsBytes();
} catch (e) {
Log.error('Could not read bytes from file: $e');
return null;
}
} }
if (image == null) return null; if (image == null) return null;
final img = await image!.toByteData(format: io.ImageByteFormat.png); final img = await image!.toByteData(format: io.ImageByteFormat.png);
@ -61,7 +71,8 @@ class ScreenshotController {
var tmpPixelRatio = pixelRatio; var tmpPixelRatio = pixelRatio;
if (tmpPixelRatio == null) { if (tmpPixelRatio == null) {
if (context != null && context.mounted) { if (context != null && context.mounted) {
tmpPixelRatio = tmpPixelRatio ?? MediaQuery.of(context).devicePixelRatio; tmpPixelRatio =
tmpPixelRatio ?? MediaQuery.of(context).devicePixelRatio;
} }
} }
final image = await boundary.toImage(pixelRatio: tmpPixelRatio ?? 1); final image = await boundary.toImage(pixelRatio: tmpPixelRatio ?? 1);

View file

@ -254,17 +254,32 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
} }
} }
Future<void> requestMicrophonePermission() async { Future<void> requestMicrophonePermission({int retryCount = 0}) async {
final statuses = await [ try {
Permission.microphone, final statuses = await [
].request(); Permission.microphone,
if (statuses[Permission.microphone]!.isPermanentlyDenied) { ].request();
await openAppSettings(); if (statuses[Permission.microphone]!.isPermanentlyDenied) {
} else { await openAppSettings();
_hasAudioPermission = await Permission.microphone.isGranted; } else {
setState(() { _hasAudioPermission = await Permission.microphone.isGranted;
// _hasAudioPermission setState(() {
}); // _hasAudioPermission
});
}
} on PlatformException catch (e) {
if (e.message?.contains('already running') ?? false) {
if (retryCount < 5) {
Log.warn(
'Microphone permission request conflict, retrying in 300ms... (attempt ${retryCount + 1})',
);
await Future.delayed(const Duration(milliseconds: 300));
return requestMicrophonePermission(retryCount: retryCount + 1);
}
}
Log.error('PlatformException in requestMicrophonePermission: $e');
} catch (e) {
Log.error('Error in requestMicrophonePermission: $e');
} }
} }

View file

@ -8,6 +8,7 @@ import 'package:twonly/locator.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/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
class SaveToGalleryButton extends StatefulWidget { class SaveToGalleryButton extends StatefulWidget {
@ -33,18 +34,11 @@ class SaveToGalleryButtonState extends State<SaveToGalleryButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OutlinedButton( final isEnabled = !widget.isLoading && !_imageSaving;
style: OutlinedButton.styleFrom( return MyButton(
iconColor: _imageSaved variant: MyButtonVariant.secondaryDense,
? Theme.of(context).colorScheme.outline onPressed: isEnabled
: Theme.of(context).colorScheme.primary, ? () async {
foregroundColor: _imageSaved
? Theme.of(context).colorScheme.outline
: Theme.of(context).colorScheme.primary,
),
onPressed: (widget.isLoading)
? null
: () async {
setState(() { setState(() {
_imageSaving = true; _imageSaving = true;
}); });
@ -83,19 +77,24 @@ class SaveToGalleryButtonState extends State<SaveToGalleryButton> {
_imageSaving = false; _imageSaving = false;
}); });
} }
}, }
: null,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
if (_imageSaving || widget.isLoading) if (_imageSaving || widget.isLoading)
const SizedBox( const SizedBox(
width: 12, width: 12,
height: 12, height: 12,
child: CircularProgressIndicator.adaptive(strokeWidth: 1), child: CircularProgressIndicator.adaptive(
strokeWidth: 1,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
) )
else else
_imageSaved _imageSaved
? const Icon(Icons.check) ? const Icon(Icons.check, size: 14)
: const FaIcon(FontAwesomeIcons.floppyDisk), : const FaIcon(FontAwesomeIcons.floppyDisk, size: 14),
if (widget.displayButtonLabel) const SizedBox(width: 10), if (widget.displayButtonLabel) const SizedBox(width: 10),
if (widget.displayButtonLabel) if (widget.displayButtonLabel)
Text( Text(

View file

@ -17,6 +17,7 @@ import 'package:twonly/src/visual/components/contact_request_badge.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/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
import 'package:twonly/src/visual/elements/headline.element.dart'; import 'package:twonly/src/visual/elements/headline.element.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/best_friends_selector.dart'; import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/best_friends_selector.dart';
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/shortcut_row.comp.dart'; import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/shortcut_row.comp.dart';
@ -111,7 +112,9 @@ class _ShareImageView extends State<ShareImageView> {
for (final group in groups) { for (final group in groups) {
if (group.pinned) continue; if (group.pinned) continue;
if (!group.archived && getFlameCounterFromGroup(group).counter > 0 && bestFriends.length < 6) { if (!group.archived &&
getFlameCounterFromGroup(group).counter > 0 &&
bestFriends.length < 6) {
bestFriends.add(group); bestFriends.add(group);
} else { } else {
otherUsers.add(group); otherUsers.add(group);
@ -131,7 +134,10 @@ class _ShareImageView extends State<ShareImageView> {
await updateGroups( await updateGroups(
_allGroups _allGroups
.where( .where(
(x) => !x.archived || !hideArchivedUsers || widget.selectedGroupIds.contains(x.groupId), (x) =>
!x.archived ||
!hideArchivedUsers ||
widget.selectedGroupIds.contains(x.groupId),
) )
.toList(), .toList(),
); );
@ -193,7 +199,8 @@ class _ShareImageView extends State<ShareImageView> {
selectedGroupIds: widget.selectedGroupIds, selectedGroupIds: widget.selectedGroupIds,
updateSelectedGroupIds: updateSelectedGroupIds, updateSelectedGroupIds: updateSelectedGroupIds,
title: context.lang.shareImagePinnedContacts, title: context.lang.shareImagePinnedContacts,
showSelectAll: !widget.mediaFileService.mediaFile.requiresAuthentication, showSelectAll:
!widget.mediaFileService.mediaFile.requiresAuthentication,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
BestFriendsSelector( BestFriendsSelector(
@ -201,7 +208,8 @@ class _ShareImageView extends State<ShareImageView> {
selectedGroupIds: widget.selectedGroupIds, selectedGroupIds: widget.selectedGroupIds,
updateSelectedGroupIds: updateSelectedGroupIds, updateSelectedGroupIds: updateSelectedGroupIds,
title: context.lang.shareImageBestFriends, title: context.lang.shareImageBestFriends,
showSelectAll: !widget.mediaFileService.mediaFile.requiresAuthentication, showSelectAll:
!widget.mediaFileService.mediaFile.requiresAuthentication,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
if (_otherUsers.isNotEmpty) if (_otherUsers.isNotEmpty)
@ -264,7 +272,8 @@ class _ShareImageView extends State<ShareImageView> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
if (widget.mediaFileService.mediaFile.type == MediaType.image && if (widget.mediaFileService.mediaFile.type ==
MediaType.image &&
_screenshotImage?.image != null && _screenshotImage?.image != null &&
userService.currentUser.showShowImagePreviewWhenSending) userService.currentUser.showShowImagePreviewWhenSending)
SizedBox( SizedBox(
@ -288,50 +297,53 @@ class _ShareImageView extends State<ShareImageView> {
), ),
), ),
), ),
FilledButton.icon( MyButton(
icon: !mediaStoreFutureReady || sendingImage variant: MyButtonVariant.primaryMiddle,
? SizedBox( onPressed:
height: 12, !mediaStoreFutureReady ||
width: 12, widget.selectedGroupIds.isEmpty ||
sendingImage
? null
: () async {
setState(() {
sendingImage = true;
});
// in case mediaStoreFutureReady is ready, the image is stored in the originalPath
await insertMediaFileInMessagesTable(
widget.mediaFileService,
widget.selectedGroupIds.toList(),
additionalData: widget.additionalData,
);
if (context.mounted) {
Navigator.pop(context, true);
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!mediaStoreFutureReady || sendingImage)
const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator.adaptive( child: CircularProgressIndicator.adaptive(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary), valueColor: AlwaysStoppedAnimation(
Colors.black87,
),
), ),
) )
: const FaIcon(FontAwesomeIcons.solidPaperPlane), else
onPressed: () async { const FaIcon(
if (!mediaStoreFutureReady || widget.selectedGroupIds.isEmpty) { FontAwesomeIcons.solidPaperPlane,
return; size: 14,
} ),
const SizedBox(width: 8),
setState(() { Text(
sendingImage = true; '${context.lang.shareImagedEditorSendImage} (${widget.selectedGroupIds.length})',
}); ),
],
// in case mediaStoreFutureReady is ready, the image is stored in the originalPath
await insertMediaFileInMessagesTable(
widget.mediaFileService,
widget.selectedGroupIds.toList(),
additionalData: widget.additionalData,
);
if (context.mounted) {
Navigator.pop(context, true);
}
},
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
const EdgeInsets.symmetric(vertical: 10, horizontal: 30),
),
backgroundColor: WidgetStateProperty.all<Color>(
!mediaStoreFutureReady || widget.selectedGroupIds.isEmpty
? context.color.onSurface
: context.color.primary,
),
),
label: Text(
'${context.lang.shareImagedEditorSendImage} (${widget.selectedGroupIds.length})',
style: const TextStyle(fontSize: 17),
), ),
), ),
], ],

View file

@ -2,6 +2,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:typed_data';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -18,6 +19,7 @@ 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/visual/components/emoji_picker.bottom.dart'; import 'package:twonly/src/visual/components/emoji_picker.bottom.dart';
import 'package:twonly/src/visual/components/notification_badge.comp.dart'; import 'package:twonly/src/visual/components/notification_badge.comp.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart'; import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart';
@ -214,7 +216,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
List<Widget> get actionsAtTheRight { List<Widget> get actionsAtTheRight {
if (layers.isNotEmpty && if (layers.isNotEmpty &&
(layers.first.isEditing || (layers.last.isEditing && layers.last.hasCustomActionButtons))) { (layers.first.isEditing ||
(layers.last.isEditing && layers.last.hasCustomActionButtons))) {
return []; return [];
} }
return <Widget>[ return <Widget>[
@ -290,9 +293,13 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
if (media.type == MediaType.video) ...[ if (media.type == MediaType.video) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
ActionButton( ActionButton(
(mediaService.removeAudio) ? Icons.volume_off_rounded : Icons.volume_up_rounded, (mediaService.removeAudio)
? Icons.volume_off_rounded
: Icons.volume_up_rounded,
tooltipText: 'Enable Audio in Video', tooltipText: 'Enable Audio in Video',
color: (mediaService.removeAudio) ? Colors.white.withAlpha(160) : Colors.white, color: (mediaService.removeAudio)
? Colors.white.withAlpha(160)
: Colors.white,
onPressed: () async { onPressed: () async {
await mediaService.toggleRemoveAudio(); await mediaService.toggleRemoveAudio();
if (mediaService.removeAudio) { if (mediaService.removeAudio) {
@ -330,7 +337,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
ActionButton( ActionButton(
FontAwesomeIcons.shieldHeart, FontAwesomeIcons.shieldHeart,
tooltipText: context.lang.protectAsARealTwonly, tooltipText: context.lang.protectAsARealTwonly,
color: media.requiresAuthentication ? Theme.of(context).colorScheme.primary : Colors.white, color: media.requiresAuthentication
? Theme.of(context).colorScheme.primary
: Colors.white,
onPressed: () async { onPressed: () async {
await mediaService.setRequiresAuth(!media.requiresAuthentication); await mediaService.setRequiresAuth(!media.requiresAuthentication);
selectedGroupIds = HashSet(); selectedGroupIds = HashSet();
@ -376,7 +385,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
List<Widget> get actionsAtTheTop { List<Widget> get actionsAtTheTop {
if (layers.isNotEmpty && if (layers.isNotEmpty &&
(layers.first.isEditing || (layers.last.isEditing && layers.last.hasCustomActionButtons))) { (layers.first.isEditing ||
(layers.last.isEditing && layers.last.hasCustomActionButtons))) {
return []; return [];
} }
return [ return [
@ -468,7 +478,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
} }
if (layers.length == 2) { if (layers.length == 2) {
final filterLayer = layers[1]; final filterLayer = layers[1];
if (layers.first is BackgroundLayerData && filterLayer is FilterLayerData) { if (layers.first is BackgroundLayerData &&
filterLayer is FilterLayerData) {
if (filterLayer.page == 1) { if (filterLayer.page == 1) {
return (layers.first as BackgroundLayerData).image.image; return (layers.first as BackgroundLayerData).image.image;
} }
@ -501,6 +512,17 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
} }
Future<ScreenshotImageHelper?> storeImageAsOriginal() async { Future<ScreenshotImageHelper?> storeImageAsOriginal() async {
Uint8List? gifBytes;
ScreenshotImageHelper? image;
if (media.type == MediaType.gif) {
gifBytes = await widget.screenshotImage?.getBytes();
} else {
image = await getEditedImageBytes();
if (image != null) {
await image.getBytes();
}
}
if (mediaService.overlayImagePath.existsSync()) { if (mediaService.overlayImagePath.existsSync()) {
mediaService.overlayImagePath.deleteSync(); mediaService.overlayImagePath.deleteSync();
} }
@ -512,14 +534,12 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
mediaService.originalPath.deleteSync(); mediaService.originalPath.deleteSync();
} }
} }
ScreenshotImageHelper? image;
if (media.type == MediaType.gif) { if (media.type == MediaType.gif) {
final bytes = await widget.screenshotImage?.getBytes(); if (gifBytes != null) {
if (bytes != null) { mediaService.originalPath.writeAsBytesSync(gifBytes.toList());
mediaService.originalPath.writeAsBytesSync(bytes.toList());
} }
} else { } else {
image = await getEditedImageBytes();
if (image == null) return null; if (image == null) return null;
final bytes = await image.getBytes(); final bytes = await image.getBytes();
if (bytes == null) { if (bytes == null) {
@ -657,7 +677,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
await askToCloseThenClose(); await askToCloseThenClose();
}, },
child: Scaffold( child: Scaffold(
backgroundColor: widget.sharedFromGallery ? null : Colors.white.withAlpha(0), backgroundColor: widget.sharedFromGallery
? null
: Colors.white.withAlpha(0),
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
body: Stack( body: Stack(
fit: StackFit.expand, fit: StackFit.expand,
@ -701,49 +723,57 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
), ),
if (widget.sendToGroup != null) const SizedBox(width: 10), if (widget.sendToGroup != null) const SizedBox(width: 10),
if (widget.sendToGroup != null) if (widget.sendToGroup != null)
OutlinedButton( MyButton(
style: OutlinedButton.styleFrom( variant: MyButtonVariant.secondaryDense,
iconColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.primary,
),
onPressed: pushShareImageView, onPressed: pushShareImageView,
child: const FaIcon(FontAwesomeIcons.userPlus), child: const FaIcon(
FontAwesomeIcons.userPlus,
size: 14,
),
), ),
SizedBox(width: widget.sendToGroup == null ? 20 : 10), SizedBox(width: widget.sendToGroup == null ? 20 : 10),
FilledButton.icon( IntrinsicWidth(
icon: sendingOrLoadingImage child: MyButton(
? SizedBox( variant: MyButtonVariant.primaryMiddle,
height: 12, onPressed: sendingOrLoadingImage
width: 12, ? null
child: CircularProgressIndicator.adaptive( : () async {
strokeWidth: 2, if (widget.sendToGroup == null) {
valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary), return pushShareImageView();
}
await sendImageToSinglePerson();
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (sendingOrLoadingImage)
const SizedBox(
height: 12,
width: 12,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(
Colors.black87,
),
),
)
else
const FaIcon(
FontAwesomeIcons.solidPaperPlane,
size: 14,
), ),
) const SizedBox(width: 8),
: const FaIcon(FontAwesomeIcons.solidPaperPlane), Text(
onPressed: () async { (widget.sendToGroup == null)
if (sendingOrLoadingImage) return; ? context.lang.shareImagedEditorShareWith
if (widget.sendToGroup == null) { : substringBy(
return pushShareImageView(); widget.sendToGroup!.groupName,
} 15,
await sendImageToSinglePerson(); ),
}, ),
style: ButtonStyle( ],
padding: WidgetStateProperty.all<EdgeInsets>(
const EdgeInsets.symmetric(
vertical: 10,
horizontal: 30,
),
), ),
), ),
label: Text(
(widget.sendToGroup == null)
? context.lang.shareImagedEditorShareWith
: substringBy(widget.sendToGroup!.groupName, 15),
style: const TextStyle(fontSize: 17),
),
), ),
], ],
), ),

View file

@ -40,7 +40,10 @@ class _ChatListViewState extends State<ChatListView> {
bool _hasContacts = false; bool _hasContacts = false;
bool _loading = true; bool _loading = true;
bool get _hasOpenGroup => _groupsNotPinned.isNotEmpty || _groupsArchived.isNotEmpty || _groupsPinned.isNotEmpty; bool get _hasOpenGroup =>
_groupsNotPinned.isNotEmpty ||
_groupsArchived.isNotEmpty ||
_groupsPinned.isNotEmpty;
GlobalKey searchForOtherUsers = GlobalKey(); GlobalKey searchForOtherUsers = GlobalKey();
bool showFeedbackShortcut = false; bool showFeedbackShortcut = false;
@ -64,35 +67,43 @@ class _ChatListViewState extends State<ChatListView> {
_contactsSub = stream.listen((groups) { _contactsSub = stream.listen((groups) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_groupsNotPinned = groups.where((x) => !x.pinned && !x.archived).toList(); _groupsNotPinned = groups
.where((x) => !x.pinned && !x.archived)
.toList();
_groupsPinned = groups.where((x) => x.pinned && !x.archived).toList(); _groupsPinned = groups.where((x) => x.pinned && !x.archived).toList();
_groupsArchived = groups.where((x) => x.archived).toList(); _groupsArchived = groups.where((x) => x.archived).toList();
_loading = false; _loading = false;
}); });
}); });
_contactsCountSub = twonlyDB.contactsDao.watchAllAcceptedContacts().listen((contacts) { _contactsCountSub = twonlyDB.contactsDao.watchAllAcceptedContacts().listen((
contacts,
) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_hasContacts = contacts.isNotEmpty; _hasContacts = contacts.isNotEmpty;
}); });
}); });
_countContactRequestStream = twonlyDB.contactsDao.watchContactsRequestedCount().listen((update) { _countContactRequestStream = twonlyDB.contactsDao
if (update != null) { .watchContactsRequestedCount()
if (!mounted) return; .listen((update) {
setState(() { if (update != null) {
_countContactRequest = update; if (!mounted) return;
setState(() {
_countContactRequest = update;
});
}
}); });
}
});
_countAnnouncedStream = twonlyDB.userDiscoveryDao.watchNewAnnouncementsWithDataCount().listen((update) { _countAnnouncedStream = twonlyDB.userDiscoveryDao
if (!mounted) return; .watchNewAnnouncementsWithDataCount()
setState(() { .listen((update) {
_countAnnouncedUsers = update; if (!mounted) return;
}); setState(() {
}); _countAnnouncedUsers = update;
});
});
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final changeLog = await rootBundle.loadString('CHANGELOG.md'); final changeLog = await rootBundle.loadString('CHANGELOG.md');
@ -101,7 +112,8 @@ class _ChatListViewState extends State<ChatListView> {
changeLog.codeUnits, changeLog.codeUnits,
)).bytes; )).bytes;
if (!userService.currentUser.hideChangeLog && if (!userService.currentUser.hideChangeLog &&
userService.currentUser.lastChangeLogHash.toString() != changeLogHash.toString()) { userService.currentUser.lastChangeLogHash.toString() !=
changeLogHash.toString()) {
await UserService.update((u) { await UserService.update((u) {
u.lastChangeLogHash = changeLogHash; u.lastChangeLogHash = changeLogHash;
}); });
@ -190,11 +202,16 @@ class _ChatListViewState extends State<ChatListView> {
), ),
Center( Center(
child: NotificationBadgeComp( child: NotificationBadgeComp(
backgroundColor: isDarkMode(context) ? Colors.white : Colors.black, backgroundColor: isDarkMode(context)
? Colors.white
: Colors.black,
textColor: isDarkMode(context) ? Colors.black : Colors.white, textColor: isDarkMode(context) ? Colors.black : Colors.white,
count: (_countAnnouncedUsers + _countContactRequest).toString(), count: (_countAnnouncedUsers + _countContactRequest)
.toString(),
child: IconButton( child: IconButton(
color: (_countAnnouncedUsers + _countContactRequest > 0) ? Colors.black : null, color: (_countAnnouncedUsers + _countContactRequest > 0)
? Colors.black
: null,
key: searchForOtherUsers, key: searchForOtherUsers,
icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18), icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18),
onPressed: () => context.push(Routes.chatsAddNewUser), onPressed: () => context.push(Routes.chatsAddNewUser),
@ -240,7 +257,10 @@ class _ChatListViewState extends State<ChatListView> {
_groupsNotPinned.length + _groupsNotPinned.length +
(_groupsArchived.isNotEmpty ? 1 : 0), (_groupsArchived.isNotEmpty ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= _groupsNotPinned.length + _groupsPinned.length + (_groupsPinned.isNotEmpty ? 1 : 0)) { if (index >=
_groupsNotPinned.length +
_groupsPinned.length +
(_groupsPinned.isNotEmpty ? 1 : 0)) {
if (_groupsArchived.isEmpty) return Container(); if (_groupsArchived.isEmpty) return Container();
return ListTile( return ListTile(
title: Text( title: Text(
@ -291,32 +311,33 @@ class _ChatListViewState extends State<ChatListView> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Material( FloatingActionButton(
elevation: 3, heroTag: 'qrcode_fab',
shape: const CircleBorder(), elevation: 2,
color: context.color.primary, backgroundColor: isDarkMode(context)
child: InkWell( ? Colors.grey[800]
borderRadius: BorderRadius.circular(12), : Colors.grey[200],
onTap: () => context.push(Routes.settingsPublicProfile), foregroundColor: isDarkMode(context)
child: SizedBox( ? Colors.white
width: 45, : Colors.black87,
height: 45, onPressed: () => context.push(Routes.settingsPublicProfile),
child: Center( child: FaIcon(
child: FaIcon( FontAwesomeIcons.qrcode,
FontAwesomeIcons.qrcode, color: isDarkMode(context)
color: isDarkMode(context) ? Colors.black : Colors.white, ? Colors.white
), : Colors.black87,
),
),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
FloatingActionButton( FloatingActionButton(
backgroundColor: context.color.primary, heroTag: 'new_chat_fab',
elevation: 2,
backgroundColor: primaryColor,
foregroundColor: Colors.black87,
onPressed: () => context.push(Routes.chatsStartNewChat), onPressed: () => context.push(Routes.chatsStartNewChat),
child: FaIcon( child: const FaIcon(
FontAwesomeIcons.penToSquare, FontAwesomeIcons.penToSquare,
color: isDarkMode(context) ? Colors.black : Colors.white, color: Colors.black87,
), ),
), ),
], ],

View file

@ -1,14 +1,16 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart' show FaIcon, FontAwesomeIcons; import 'package:font_awesome_flutter/font_awesome_flutter.dart'
show FaIcon, FontAwesomeIcons;
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.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/services/signal/identity.signal.dart'; import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/profile_qr_code.comp.dart'; import 'package:twonly/src/visual/components/profile_qr_code.comp.dart'
import 'package:twonly/src/visual/themes/light.dart'; show ProfileQrCodeComp;
import 'package:twonly/src/visual/elements/my_button.element.dart';
class EmptyChatListComp extends StatelessWidget { class EmptyChatListComp extends StatelessWidget {
const EmptyChatListComp({super.key}); const EmptyChatListComp({super.key});
@ -17,7 +19,8 @@ class EmptyChatListComp extends StatelessWidget {
try { try {
final pubKey = await getUserPublicKey(); final pubKey = await getUserPublicKey();
final params = ShareParams( final params = ShareParams(
text: 'https://me.twonly.eu/${userService.currentUser.username}#${base64Url.encode(pubKey)}', text:
'https://me.twonly.eu/${userService.currentUser.username}#${base64Url.encode(pubKey)}',
); );
await SharePlus.instance.share(params); await SharePlus.instance.share(params);
} catch (e) { } catch (e) {
@ -37,16 +40,16 @@ class EmptyChatListComp extends StatelessWidget {
height: 24, height: 24,
width: double.infinity, width: double.infinity,
), ),
const Text( Text(
'Find your first friend', context.lang.emptyChatListTitle,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: const TextStyle(
fontSize: 28, fontSize: 28,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Let friends scan your QR code, or share them your profile.', context.lang.emptyChatListDesc,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: context.color.onSurface.withValues(alpha: 0.6), color: context.color.onSurface.withValues(alpha: 0.6),
@ -56,61 +59,69 @@ class EmptyChatListComp extends StatelessWidget {
const SizedBox(height: 36), const SizedBox(height: 36),
const Center(child: ProfileQrCodeComp()), const Center(child: ProfileQrCodeComp()),
const SizedBox(height: 36), const SizedBox(height: 36),
// 3. Action Buttons IntrinsicWidth(
// Button 1: Share Profile (Full Width) child: MyButton(
FilledButton.icon( onPressed: () => _shareProfile(context),
style: primaryColorButtonStyle, child: Row(
onPressed: () => _shareProfile(context), mainAxisAlignment: MainAxisAlignment.center,
icon: const FaIcon(FontAwesomeIcons.shareNodes, size: 20), children: [
label: const Text( const FaIcon(FontAwesomeIcons.shareNodes, size: 20),
'Share your profile', const SizedBox(width: 8),
style: TextStyle( Text(
fontSize: 16, context.lang.emptyChatListShareBtn,
fontWeight: FontWeight.bold, style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
), ),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Button Row: Scan QR Code & Enter Username
Row( Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Expanded( MyButton(
child: FilledButton.icon( variant: MyButtonVariant.secondaryDense,
style: secondaryGreyButtonStyle(context), onPressed: () => context.push(Routes.cameraQRScanner),
onPressed: () => context.push(Routes.cameraQRScanner), child: Row(
icon: const Icon(Icons.qr_code_scanner_rounded, size: 20), mainAxisSize: MainAxisSize.min,
label: const FittedBox( children: [
fit: BoxFit.scaleDown, const Icon(Icons.qr_code_scanner_rounded, size: 20),
child: Text( const SizedBox(width: 8),
'Scan QR Code', Text(
style: TextStyle( context.lang.emptyChatListScanBtn,
style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
), ],
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( MyButton(
child: FilledButton.icon( variant: MyButtonVariant.secondaryDense,
style: secondaryGreyButtonStyle(context), onPressed: () => context.push(Routes.chatsAddNewUser),
onPressed: () => context.push(Routes.chatsAddNewUser), child: Row(
icon: const Icon(Icons.person_add_rounded, size: 20), mainAxisSize: MainAxisSize.min,
label: const FittedBox( children: [
fit: BoxFit.scaleDown, const Icon(Icons.person_add_rounded, size: 20),
child: Text( const SizedBox(width: 8),
'Add by Username', Text(
style: TextStyle( context.lang.emptyChatListAddUsernameBtn,
style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
), ],
), ),
), ),
], ],
), ),
const SizedBox(height: 50), const SizedBox(height: 50),
], ],
), ),

View file

@ -76,7 +76,7 @@ class _AnimatedNewMessageState extends State<AnimatedNewMessage>
parent: _controller, parent: _controller,
curve: Curves.easeOut, curve: Curves.easeOut,
), ),
axisAlignment: 1, alignment: Alignment.bottomLeft,
child: ScaleTransition( child: ScaleTransition(
scale: _scaleAnimation, scale: _scaleAnimation,
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,

View file

@ -31,34 +31,27 @@ class ChatAudioEntry extends StatelessWidget {
return Container(); // media file was purged return Container(); // media file was purged
} }
return LayoutBuilder( final showTime = info.displayTime || message.modifiedAt != null;
builder: (context, constraints) {
final textWidth = measureTextWidth(info.text);
const timeWidth = 60.0;
final isExpanded =
info.expanded ||
(textWidth + timeWidth + 20 > constraints.maxWidth);
final effectiveSpacerWidth =
constraints.minWidth - textWidth - timeWidth;
final spacerWidth = effectiveSpacerWidth > 0
? effectiveSpacerWidth
: 0.0;
return Container( return IntrinsicWidth(
constraints: BoxConstraints( child: Container(
maxWidth: MediaQuery.of(context).size.width * 0.8, constraints: BoxConstraints(
minWidth: 250, maxWidth: MediaQuery.of(context).size.width * 0.8,
), minWidth: 280,
padding: info.padding, ),
decoration: BoxDecoration( padding: info.padding,
color: info.color, decoration: BoxDecoration(
borderRadius: borderRadius, color: info.color,
), borderRadius: borderRadius,
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
if (info.displayUserName != '') mainAxisSize: MainAxisSize.min,
Text( children: [
if (info.displayUserName != '')
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
info.displayUserName, info.displayUserName,
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
@ -66,42 +59,37 @@ class ChatAudioEntry extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (isExpanded && info.text != '')
Expanded(
child: BetterText(
text: info.text,
textColor: info.textColor,
),
)
else if (info.text != '') ...[
BetterText(text: info.text, textColor: info.textColor),
SizedBox(width: spacerWidth),
] else ...[
if (mediaService.mediaFile.downloadState ==
DownloadState.ready ||
mediaService.mediaFile.downloadState == null)
mediaService.tempPath.existsSync()
? InChatAudioPlayer(
path: mediaService.tempPath.path,
message: message,
)
: Container()
else
MessageSendStateIcon([message], [mediaService.mediaFile]),
SizedBox(width: spacerWidth),
],
if (info.displayTime || message.modifiedAt != null)
FriendlyMessageTime(message: message),
],
), ),
], Row(
), mainAxisSize: MainAxisSize.min,
); crossAxisAlignment: CrossAxisAlignment.end,
}, children: [
if (info.text != '')
Expanded(
child: BetterText(
text: info.text,
textColor: info.textColor,
),
)
else
Expanded(
child: mediaService.mediaFile.downloadState ==
DownloadState.ready ||
mediaService.mediaFile.downloadState == null
? (mediaService.tempPath.existsSync()
? InChatAudioPlayer(
path: mediaService.tempPath.path,
message: message,
)
: Container())
: MessageSendStateIcon([message], [mediaService.mediaFile]),
),
if (showTime) FriendlyMessageTime(message: message),
],
),
],
),
),
); );
} }
} }

View file

@ -34,34 +34,27 @@ class ChatTextEntry extends StatelessWidget {
); );
} }
return LayoutBuilder( final showTime = info.displayTime || message.modifiedAt != null;
builder: (context, constraints) {
final textWidth = measureTextWidth(info.text);
const timeWidth = 60.0;
final isExpanded =
info.expanded ||
(textWidth + timeWidth + 20 > constraints.maxWidth);
final effectiveSpacerWidth =
constraints.minWidth - textWidth - timeWidth;
final spacerWidth = effectiveSpacerWidth > 0
? effectiveSpacerWidth
: 0.0;
return Container( return IntrinsicWidth(
constraints: BoxConstraints( child: Container(
maxWidth: MediaQuery.of(context).size.width * 0.8, constraints: BoxConstraints(
minWidth: info.minWidth, maxWidth: MediaQuery.of(context).size.width * 0.8,
), minWidth: info.minWidth,
padding: info.padding, ),
decoration: BoxDecoration( padding: info.padding,
color: info.color, decoration: BoxDecoration(
borderRadius: borderRadius, color: info.color,
), borderRadius: borderRadius,
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
if (info.displayUserName != '') mainAxisSize: MainAxisSize.min,
Text( children: [
if (info.displayUserName != '')
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
info.displayUserName, info.displayUserName,
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
@ -69,31 +62,23 @@ class ChatTextEntry extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (isExpanded)
Expanded(
child: BetterText(
text: info.text,
textColor: info.textColor,
),
)
else ...[
BetterText(text: info.text, textColor: info.textColor),
SizedBox(
width: spacerWidth,
),
],
if (info.displayTime || message.modifiedAt != null)
FriendlyMessageTime(message: message),
],
), ),
], Row(
), mainAxisSize: MainAxisSize.min,
); crossAxisAlignment: CrossAxisAlignment.end,
}, children: [
Expanded(
child: BetterText(
text: info.text,
textColor: info.textColor,
),
),
if (showTime) FriendlyMessageTime(message: message),
],
),
],
),
),
); );
} }
} }

View file

@ -76,7 +76,7 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
SizedBox( SizedBox(
width: 10, width: 10,
height: 10, height: 10,
child: CircularProgressIndicator.adaptive(strokeWidth: 1, valueColor: AlwaysStoppedAnimation(color)), child: CircularProgressIndicator(strokeWidth: 1, color: color),
), ),
const SizedBox(width: 2), const SizedBox(width: 2),
], ],

View file

@ -9,7 +9,7 @@ import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/views/chats/chat_messages.view.dart'; import 'package:twonly/src/visual/views/chats/chat_messages.view.dart';
class ResponseContainer extends StatefulWidget { class ResponseContainer extends StatelessWidget {
const ResponseContainer({ const ResponseContainer({
required this.msg, required this.msg,
required this.group, required this.group,
@ -27,82 +27,55 @@ class ResponseContainer extends StatefulWidget {
final BorderRadius borderRadius; final BorderRadius borderRadius;
final void Function(String)? scrollToMessage; final void Function(String)? scrollToMessage;
@override
State<ResponseContainer> createState() => _ResponseContainerState();
}
class _ResponseContainerState extends State<ResponseContainer> {
double? minWidth;
final GlobalKey _message = GlobalKey();
final GlobalKey _preview = GlobalKey();
@override
void didChangeDependencies() {
super.didChangeDependencies();
WidgetsBinding.instance.addPostFrameCallback((_) {
final messageBox = _message.currentContext?.findRenderObject() as RenderBox?;
final previewBox = _preview.currentContext?.findRenderObject() as RenderBox?;
if (messageBox == null || previewBox == null) {
return;
}
setState(() {
if (messageBox.size.width > previewBox.size.width) {
minWidth = messageBox.size.width;
} else {
minWidth = previewBox.size.width;
}
});
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.msg.quotesMessageId == null) { if (msg.quotesMessageId == null) {
if (widget.child == null) { if (child == null) {
return Container(); return Container();
} }
return widget.child!; return child!;
} }
return GestureDetector( return GestureDetector(
onTap: widget.scrollToMessage == null ? null : () => widget.scrollToMessage!(widget.msg.quotesMessageId!), onTap: scrollToMessage == null
? null
: () => scrollToMessage!(msg.quotesMessageId!),
child: Container( child: Container(
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8, maxWidth: MediaQuery.of(context).size.width * 0.8,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: getMessageColor(widget.msg.senderId != null), color: getMessageColor(msg.senderId != null),
borderRadius: widget.borderRadius, borderRadius: borderRadius,
), ),
child: Column( child: IntrinsicWidth(
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
Padding( crossAxisAlignment: CrossAxisAlignment.stretch,
key: _preview, children: [
padding: const EdgeInsets.only(top: 4, right: 4, left: 4), Padding(
child: Container( padding: const EdgeInsets.only(top: 4, right: 4, left: 4),
width: minWidth, child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.color.surface.withAlpha(150), color: context.color.surface.withAlpha(150),
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topRight: Radius.circular(8), topRight: Radius.circular(8),
topLeft: Radius.circular(8), topLeft: Radius.circular(8),
bottomLeft: Radius.circular(4), bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(4), bottomRight: Radius.circular(4),
),
),
child: ResponsePreview(
group: group,
messageId: msg.quotesMessageId,
showBorder: false,
showLeftBorder: false,
colorUsername: false,
), ),
), ),
child: ResponsePreview(
group: widget.group,
messageId: widget.msg.quotesMessageId,
showBorder: false,
),
), ),
), if (child != null) child!,
SizedBox( ],
key: _message, ),
width: minWidth,
child: widget.child,
),
],
), ),
), ),
); );
@ -115,6 +88,8 @@ class ResponsePreview extends StatefulWidget {
required this.showBorder, required this.showBorder,
this.message, this.message,
this.messageId, this.messageId,
this.showLeftBorder = true,
this.colorUsername = false,
super.key, super.key,
}); });
@ -122,6 +97,8 @@ class ResponsePreview extends StatefulWidget {
final String? messageId; final String? messageId;
final Group group; final Group group;
final bool showBorder; final bool showBorder;
final bool showLeftBorder;
final bool colorUsername;
@override @override
State<ResponsePreview> createState() => _ResponsePreviewState(); State<ResponsePreview> createState() => _ResponsePreviewState();
@ -140,12 +117,16 @@ class _ResponsePreviewState extends State<ResponsePreview> {
} }
Future<void> initAsync() async { Future<void> initAsync() async {
_message ??= await twonlyDB.messagesDao.getMessageById(widget.messageId!).getSingleOrNull(); _message ??= await twonlyDB.messagesDao
.getMessageById(widget.messageId!)
.getSingleOrNull();
if (_message?.mediaId != null) { if (_message?.mediaId != null) {
_mediaService = await MediaFileService.fromMediaId(_message!.mediaId!); _mediaService = await MediaFileService.fromMediaId(_message!.mediaId!);
} }
if (_message?.senderId != null) { if (_message?.senderId != null) {
final contact = await twonlyDB.contactsDao.getContactByUserId(_message!.senderId!).getSingleOrNull(); final contact = await twonlyDB.contactsDao
.getContactByUserId(_message!.senderId!)
.getSingleOrNull();
if (contact != null) { if (contact != null) {
_username = getContactDisplayName(contact); _username = getContactDisplayName(contact);
} }
@ -204,74 +185,85 @@ class _ResponsePreviewState extends State<ResponsePreview> {
if (_message!.senderId == null) { if (_message!.senderId == null) {
_username = context.lang.you; _username = context.lang.you;
// _username = _message!.senderId.toString();
} }
color = getMessageColor(_message!.senderId != null); color = getMessageColor(_message!.senderId != null);
}
if (!_message!.mediaStored) { final hasImage =
return Container( _message != null &&
padding: widget.showBorder _message!.mediaStored &&
? const EdgeInsets.only(left: 10, right: 10) _mediaService != null &&
: const EdgeInsets.symmetric(horizontal: 5), _mediaService!.mediaFile.type != MediaType.audio;
decoration: (widget.showBorder)
? BoxDecoration( Widget? imageWidget;
border: Border( if (hasImage) {
left: BorderSide( final isVideo = _mediaService!.mediaFile.type == MediaType.video;
color: color, final pathToCheck = isVideo
width: 2, ? _mediaService!.thumbnailPath
), : _mediaService!.storedPath;
), if (pathToCheck.existsSync() && pathToCheck.lengthSync() > 0) {
) imageWidget = Container(
: null, height: 40,
child: Column( width: 40,
crossAxisAlignment: CrossAxisAlignment.start, margin: const EdgeInsets.only(left: 8),
children: [ child: ClipRRect(
Text( borderRadius: BorderRadius.circular(4),
_username, child: Image.file(
style: const TextStyle(fontWeight: FontWeight.bold), pathToCheck,
), fit: BoxFit.cover,
if (subtitle != null) Text(subtitle), ),
],
), ),
); );
} }
} }
return Container( return Container(
padding: const EdgeInsets.only(left: 10), padding: EdgeInsets.only(
width: 200, left: widget.showLeftBorder ? 8 : 4,
decoration: BoxDecoration( right: 6,
border: Border( top: 4,
left: BorderSide( bottom: 4,
color: color,
width: 2,
),
),
), ),
constraints: BoxConstraints(
minWidth: 60,
maxWidth: MediaQuery.of(context).size.width * 0.7,
),
decoration: widget.showLeftBorder
? BoxDecoration(
border: Border(
left: BorderSide(
color: color,
width: 2.5,
),
),
)
: null,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
_username, _username,
style: const TextStyle(fontWeight: FontWeight.bold), style: TextStyle(
fontWeight: FontWeight.bold,
color: widget.colorUsername ? color : null,
),
), ),
if (subtitle != null) Text(subtitle), if (subtitle != null)
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
], ],
), ),
), ),
if (_mediaService != null && _mediaService!.mediaFile.type != MediaType.audio) if (imageWidget != null) imageWidget,
SizedBox(
height: widget.showBorder ? 100 : 210,
child: Image.file(
_mediaService!.mediaFile.type == MediaType.video
? _mediaService!.thumbnailPath
: _mediaService!.storedPath,
),
),
], ],
), ),
); );

View file

@ -158,6 +158,19 @@ class _MediaViewerViewState extends State<MediaViewerView> {
allMediaFiles.add(msg); allMediaFiles.add(msg);
} }
} }
if (allMediaFiles.length > 1) {
if (widget.initialMessage == null &&
currentMedia == null &&
!_showDownloadingLoader) {
allMediaFiles.sort(
(a, b) => a.createdAt.compareTo(b.createdAt),
);
} else {
final upcoming = allMediaFiles.sublist(1)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
allMediaFiles = [allMediaFiles.first, ...upcoming];
}
}
if (mounted) setState(() {}); if (mounted) setState(() {});
if (firstRun) { if (firstRun) {
firstRun = false; firstRun = false;
@ -698,6 +711,15 @@ class _MediaViewerViewState extends State<MediaViewerView> {
), ),
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
);
},
), ),
), ),
], ],

View file

@ -114,7 +114,7 @@ class _AddContactViaQrLinkViewState extends State<AddContactViaQrLinkView> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
context.lang.userFoundBody, context.lang.userFoundBody(widget.profile.username),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: context.color.onSurfaceVariant, color: context.color.onSurfaceVariant,

View file

@ -18,7 +18,8 @@ import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/profile_qr_code.comp.dart'; import 'package:twonly/src/visual/components/profile_qr_code.comp.dart';
import 'package:twonly/src/visual/themes/light.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
import 'package:twonly/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart'; import 'package:twonly/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart';
import 'package:twonly/src/visual/views/contact/add_new_contact_components/open_requests_list.comp.dart'; import 'package:twonly/src/visual/views/contact/add_new_contact_components/open_requests_list.comp.dart';
@ -40,6 +41,7 @@ class _SearchUsernameView extends State<AddNewUserView> {
final TextEditingController _usernameController = TextEditingController(); final TextEditingController _usernameController = TextEditingController();
bool _isLoading = false; bool _isLoading = false;
bool hasRequestedUsers = false; bool hasRequestedUsers = false;
String? _searchError;
List<Contact> _openRequestsContacts = []; List<Contact> _openRequestsContacts = [];
late StreamSubscription<List<Contact>> _contactsStream; late StreamSubscription<List<Contact>> _contactsStream;
@ -63,20 +65,24 @@ class _SearchUsernameView extends State<AddNewUserView> {
}, },
); );
_newAnnouncedUsersStream = twonlyDB.userDiscoveryDao.watchNewAnnouncedUsersWithRelations().listen((update) { _newAnnouncedUsersStream = twonlyDB.userDiscoveryDao
if (mounted) { .watchNewAnnouncedUsersWithRelations()
setState(() { .listen((update) {
_newAnnouncedUsers = update; if (mounted) {
setState(() {
_newAnnouncedUsers = update;
});
}
}); });
} _allAnnouncedUsersStream = twonlyDB.userDiscoveryDao
}); .watchAllAnnouncedUsersWithRelations()
_allAnnouncedUsersStream = twonlyDB.userDiscoveryDao.watchAllAnnouncedUsersWithRelations().listen((update) { .listen((update) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_allAnnouncedUsers = update; _allAnnouncedUsers = update;
});
}
}); });
}
});
if (widget.username != null) { if (widget.username != null) {
_usernameController.text = widget.username!; _usernameController.text = widget.username!;
@ -90,7 +96,8 @@ class _SearchUsernameView extends State<AddNewUserView> {
Future<void> _shareProfile() async { Future<void> _shareProfile() async {
final pubKey = await getUserPublicKey(); final pubKey = await getUserPublicKey();
final params = ShareParams( final params = ShareParams(
text: 'https://me.twonly.eu/${userService.currentUser.username}#${base64Url.encode(pubKey)}', text:
'https://me.twonly.eu/${userService.currentUser.username}#${base64Url.encode(pubKey)}',
); );
await SharePlus.instance.share(params); await SharePlus.instance.share(params);
} }
@ -164,18 +171,20 @@ class _SearchUsernameView extends State<AddNewUserView> {
}); });
if (userdata == null) { if (userdata == null) {
await showAlertDialog( setState(() {
context, _searchError = context.lang.searchUsernameNotFound;
context.lang.searchUsernameNotFound, });
context.lang.searchUsernameNotFoundBody(username),
);
return; return;
} }
setState(() {
_searchError = null;
});
final addUser = await showAlertDialog( final addUser = await showAlertDialog(
context, context,
context.lang.userFound(username), context.lang.userFound(username),
context.lang.userFoundBody, context.lang.userFoundBody(username),
); );
if (!addUser || !mounted) return; if (!addUser || !mounted) return;
@ -190,7 +199,9 @@ class _SearchUsernameView extends State<AddNewUserView> {
), ),
); );
if (widget.publicKey != null && mounted && widget.publicKey!.equals(userdata.publicIdentityKey)) { if (widget.publicKey != null &&
mounted &&
widget.publicKey!.equals(userdata.publicIdentityKey)) {
final markAsVerified = await showAlertDialog( final markAsVerified = await showAlertDialog(
context, context,
context.lang.linkFromUsername(username), context.lang.linkFromUsername(username),
@ -218,72 +229,93 @@ class _SearchUsernameView extends State<AddNewUserView> {
child: ListView( child: ListView(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
child: SearchBar( child: MyInput(
controller: _usernameController, controller: _usernameController,
hintText: context.lang.searchUsernameInput, hintText: context.lang.searchUsernameInput,
elevation: const WidgetStatePropertyAll(0), prefixIcon: const Icon(Icons.search, size: 20),
backgroundColor: WidgetStatePropertyAll( errorText: _searchError,
context.color.surfaceContainerHighest.withValues(alpha: 0.3),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 8),
),
leading: const Icon(Icons.search, size: 20, color: Colors.grey),
trailing: [
if (_usernameController.text.isNotEmpty) ...[
IconButton(
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_usernameController.clear();
setState(() {});
},
),
if (_isLoading)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
),
)
else
IconButton(
icon: FaIcon(
FontAwesomeIcons.magnifyingGlassPlus,
size: 20,
color: context.color.primary,
),
onPressed: () => _requestNewUserByUsername(
_usernameController.text,
),
),
] else ...[
IconButton(
icon: FaIcon(
FontAwesomeIcons.camera,
size: 20,
color: context.color.primary,
),
onPressed: () => context.push(Routes.cameraQRScanner),
tooltip: context.lang.scanOtherProfile,
),
],
],
onSubmitted: _requestNewUserByUsername,
onChanged: (value) { onChanged: (value) {
_usernameController.text = value.toLowerCase(); _usernameController.text = value.toLowerCase();
_usernameController.selection = TextSelection.fromPosition( _usernameController.selection = TextSelection.fromPosition(
TextPosition(offset: _usernameController.text.length), TextPosition(offset: _usernameController.text.length),
); );
setState(() {}); setState(() {
_searchError = null;
});
}, },
onSubmitted: _requestNewUserByUsername,
suffixIcon: _usernameController.text.isNotEmpty
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filled(
style: IconButton.styleFrom(
backgroundColor:
context.color.surfaceContainerHighest,
foregroundColor: context.color.onSurface,
minimumSize: const Size(32, 32),
padding: EdgeInsets.zero,
shape: const CircleBorder(),
),
iconSize: 16,
icon: const Icon(Icons.close),
onPressed: () {
_usernameController.clear();
setState(() {});
},
),
const SizedBox(width: 0),
if (_isLoading)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
context.color.primary,
),
),
),
)
else
IconButton.filled(
style: IconButton.styleFrom(
backgroundColor: context.color.primary,
foregroundColor: context.color.onPrimary,
minimumSize: const Size(32, 32),
padding: EdgeInsets.zero,
shape: const CircleBorder(),
),
iconSize: 16,
icon: const Icon(Icons.person_add_rounded),
onPressed: () => _requestNewUserByUsername(
_usernameController.text,
),
),
const SizedBox(width: 8),
],
)
: Padding(
padding: const EdgeInsets.only(right: 6),
child: IconButton.filled(
style: IconButton.styleFrom(
backgroundColor: context.color.primary,
foregroundColor: context.color.onPrimary,
minimumSize: const Size(36, 36),
padding: EdgeInsets.zero,
shape: const CircleBorder(),
),
iconSize: 18,
icon: const FaIcon(FontAwesomeIcons.camera),
onPressed: () => context.push(Routes.cameraQRScanner),
tooltip: context.lang.scanOtherProfile,
),
),
), ),
), ),
const SizedBox( const SizedBox(
@ -291,63 +323,40 @@ class _SearchUsernameView extends State<AddNewUserView> {
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 10), padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( MyButton(
children: [ variant: MyButtonVariant.primaryDense,
Expanded( onPressed: _shareProfile,
child: FilledButton.icon( child: Row(
style: FilledButton.styleFrom( mainAxisSize: MainAxisSize.min,
backgroundColor: primaryColor, children: [
foregroundColor: Colors.black87, const FaIcon(FontAwesomeIcons.shareNodes, size: 14),
padding: const EdgeInsets.symmetric( const SizedBox(width: 8),
vertical: 8, Text(
horizontal: 10, context.lang.shareYourProfile,
), style: const TextStyle(fontSize: 13),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: _shareProfile,
icon: const FaIcon(
FontAwesomeIcons.shareNodes,
size: 14,
),
label: Text(
context.lang.shareYourProfile,
style: const TextStyle(fontSize: 13),
),
), ),
), ],
const SizedBox(width: 8), ),
Expanded( ),
child: FilledButton.icon( const SizedBox(width: 8),
style: FilledButton.styleFrom( Expanded(
backgroundColor: context.color.secondaryContainer, child: MyButton(
foregroundColor: context.color.onSecondaryContainer, variant: MyButtonVariant.secondaryDense,
padding: const EdgeInsets.symmetric( onPressed: _showMyQrCode,
vertical: 8, child: Row(
horizontal: 10, mainAxisSize: MainAxisSize.min,
), children: [
elevation: 0, const FaIcon(FontAwesomeIcons.qrcode, size: 14),
shape: RoundedRectangleBorder( const SizedBox(width: 8),
borderRadius: BorderRadius.circular(12), Text(
),
),
onPressed: _showMyQrCode,
icon: const FaIcon(
FontAwesomeIcons.qrcode,
size: 14,
),
label: Text(
context.lang.openYourOwnQRcode, context.lang.openYourOwnQRcode,
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
), ],
), ),
], ),
), ),
], ],
), ),

View file

@ -72,7 +72,10 @@ class OpenRequestsListComp extends StatelessWidget {
if (block) { if (block) {
const update = ContactsCompanion(blocked: Value(true)); const update = ContactsCompanion(blocked: Value(true));
if (context.mounted) { if (context.mounted) {
await twonlyDB.contactsDao.updateContact(contact.userId, update); await twonlyDB.contactsDao.updateContact(
contact.userId,
update,
);
} }
} }
}, },
@ -189,7 +192,9 @@ class OpenRequestsListComp extends StatelessWidget {
), ),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: contact.requested ? requestedActions(context, contact) : sendRequestActions(context, contact), children: contact.requested
? requestedActions(context, contact)
: sendRequestActions(context, contact),
), ),
); );
}), }),

View file

@ -66,6 +66,18 @@ class MemoriesFlashbackBannerComp extends StatelessWidget {
Image.file( Image.file(
items.first.mediaService.storedPath, items.first.mediaService.storedPath,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return ColoredBox(
color: Colors.grey.shade800,
child: const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white30,
size: 32,
),
),
);
},
), ),
Positioned.fill( Positioned.fill(
child: DecoratedBox( child: DecoratedBox(

View file

@ -79,20 +79,30 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
_scaleController.value = 1.0; _scaleController.value = 1.0;
} }
_listener = ImageStreamListener((info, _) { _listener = ImageStreamListener(
if (mounted) { (info, _) {
setState(() { if (mounted) {
_imageInfo = info; setState(() {
}); _imageInfo = info;
} });
}); }
},
onError: (exception, stackTrace) {
if (mounted) {
setState(() {
_imageProvider = null;
_imageInfo = null;
});
}
},
);
_resolveImage(); _resolveImage();
} }
void _resolveImage() { void _resolveImage() {
final media = widget.galleryItem.mediaService; final media = widget.galleryItem.mediaService;
final hasThumbnail = media.thumbnailPath.existsSync(); final hasThumbnail = media.thumbnailPath.existsSync() && media.thumbnailPath.lengthSync() > 0;
final hasStored = media.storedPath.existsSync(); final hasStored = media.storedPath.existsSync() && media.storedPath.lengthSync() > 0;
final isImageOrGif = final isImageOrGif =
media.mediaFile.type == MediaType.image || media.mediaFile.type == MediaType.image ||
media.mediaFile.type == MediaType.gif; media.mediaFile.type == MediaType.gif;
@ -181,6 +191,17 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
image: _imageProvider!, image: _imageProvider!,
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true, gaplessPlayback: true,
errorBuilder: (context, error, stackTrace) {
return ColoredBox(
color: Colors.grey.shade200,
child: const Center(
child: FaIcon(
FontAwesomeIcons.image,
color: Colors.black26,
),
),
);
},
) )
else else
ColoredBox( ColoredBox(

View file

@ -193,6 +193,63 @@ class MemoriesViewState extends State<MemoriesView> {
}); });
} }
Future<void> _showProgressDialog(
String message,
Future<void> Function(void Function(double progress) setProgress) task,
) async {
final progressNotifier = ValueNotifier<double>(0);
// Show non-dismissible progress dialog
// ignore: unawaited_futures
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) {
return PopScope(
canPop: false,
child: AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Text(
message,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 20),
ValueListenableBuilder<double>(
valueListenable: progressNotifier,
builder: (context, progress, _) {
return Column(
children: [
LinearProgressIndicator(value: progress),
const SizedBox(height: 10),
Text(
'${(progress * 100).toInt()}%',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
},
),
],
),
),
);
},
);
try {
await Future<void>.delayed(Duration.zero);
await task((p) => progressNotifier.value = p);
} finally {
if (mounted) {
Navigator.of(context).pop();
}
progressNotifier.dispose();
}
}
Future<void> _batchDelete() async { Future<void> _batchDelete() async {
final count = _selectedMediaIds.length; final count = _selectedMediaIds.length;
final confirmed = await showAlertDialog( final confirmed = await showAlertDialog(
@ -204,15 +261,24 @@ class MemoriesViewState extends State<MemoriesView> {
if (!confirmed) return; if (!confirmed) return;
final items = _service.currentState.galleryItems; final items = _service.currentState.galleryItems;
for (final mediaId in _selectedMediaIds) { final selectedList = _selectedMediaIds.toList();
final item = items
.where((e) => e.mediaService.mediaFile.mediaId == mediaId) await _showProgressDialog(
.firstOrNull; 'Deleting memories...',
if (item != null) { (setProgress) async {
item.mediaService.fullMediaRemoval(); for (var i = 0; i < selectedList.length; i++) {
} final mediaId = selectedList[i];
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId); final item = items
} .where((e) => e.mediaService.mediaFile.mediaId == mediaId)
.firstOrNull;
if (item != null) {
item.mediaService.fullMediaRemoval();
}
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
setProgress((i + 1) / selectedList.length);
}
},
);
setState(_selectedMediaIds.clear); setState(_selectedMediaIds.clear);
@ -226,23 +292,34 @@ class MemoriesViewState extends State<MemoriesView> {
Future<void> _batchExport() async { Future<void> _batchExport() async {
final items = _service.currentState.galleryItems; final items = _service.currentState.galleryItems;
final selectedList = _selectedMediaIds.toList();
if (selectedList.isEmpty) return;
try { try {
for (final mediaId in _selectedMediaIds) { await _showProgressDialog(
final item = items 'Exporting memories...',
.where((e) => e.mediaService.mediaFile.mediaId == mediaId) (setProgress) async {
.firstOrNull; for (var i = 0; i < selectedList.length; i++) {
if (item != null) { final mediaId = selectedList[i];
final media = item.mediaService; final item = items
if (media.mediaFile.type == MediaType.video) { .where((e) => e.mediaService.mediaFile.mediaId == mediaId)
await saveVideoToGallery(media.storedPath.path); .firstOrNull;
} else if (media.mediaFile.type == MediaType.image || if (item != null) {
media.mediaFile.type == MediaType.gif) { final media = item.mediaService;
final imageBytes = await media.storedPath.readAsBytes(); if (media.mediaFile.type == MediaType.video) {
await saveImageToGallery(imageBytes, createdAt: media.mediaFile.createdAt); await saveVideoToGallery(media.storedPath.path);
} else if (media.mediaFile.type == MediaType.image ||
media.mediaFile.type == MediaType.gif) {
final imageBytes = await media.storedPath.readAsBytes();
await saveImageToGallery(imageBytes, createdAt: media.mediaFile.createdAt);
}
}
setProgress((i + 1) / selectedList.length);
} }
} },
} );
setState(_selectedMediaIds.clear);
if (!mounted) return; if (!mounted) return;
showSnackbar( showSnackbar(
@ -258,26 +335,36 @@ class MemoriesViewState extends State<MemoriesView> {
Future<void> _batchFavorite() async { Future<void> _batchFavorite() async {
final items = _service.currentState.galleryItems; final items = _service.currentState.galleryItems;
final selectedList = _selectedMediaIds.toList();
if (selectedList.isEmpty) return;
var favCount = 0; var favCount = 0;
for (final item in items) { for (final item in items) {
if (_selectedMediaIds.contains(item.mediaService.mediaFile.mediaId)) { if (selectedList.contains(item.mediaService.mediaFile.mediaId)) {
if (item.mediaService.mediaFile.isFavorite) { if (item.mediaService.mediaFile.isFavorite) {
favCount++; favCount++;
} }
} }
} }
final areAllFav = final areAllFav =
_selectedMediaIds.isNotEmpty && favCount == _selectedMediaIds.length; selectedList.isNotEmpty && favCount == selectedList.length;
final targetFav = !areAllFav; final targetFav = !areAllFav;
for (final mediaId in _selectedMediaIds) { await _showProgressDialog(
await twonlyDB.mediaFilesDao.updateMedia( targetFav ? 'Adding to favorites...' : 'Removing from favorites...',
mediaId, (setProgress) async {
MediaFilesCompanion(isFavorite: Value(targetFav)), for (var i = 0; i < selectedList.length; i++) {
); final mediaId = selectedList[i];
} await twonlyDB.mediaFilesDao.updateMedia(
mediaId,
MediaFilesCompanion(isFavorite: Value(targetFav)),
);
setProgress((i + 1) / selectedList.length);
}
},
);
setState(() {}); setState(_selectedMediaIds.clear);
} }
@override @override

View file

@ -347,6 +347,15 @@ class _SynchronizedImageViewerScreenState
backgroundDecoration: const BoxDecoration( backgroundDecoration: const BoxDecoration(
color: Colors.transparent, color: Colors.transparent,
), ),
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
);
},
scaleStateChangedCallback: (state) { scaleStateChangedCallback: (state) {
final zoomed = final zoomed =
state != PhotoViewScaleState.initial; state != PhotoViewScaleState.initial;

View file

@ -1,79 +0,0 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/themes/light.dart';
class OnboardingWrapper extends StatelessWidget {
const OnboardingWrapper({
required this.children,
super.key,
});
final List<Widget> children;
@override
Widget build(BuildContext context) {
final isDark = isDarkMode(context);
final backgroundColor = isDark ? const Color(0xFF0F172A) : primaryColor;
final topBlobColor = isDark
? primaryColor.withValues(alpha: 0.15)
: Colors.white.withValues(alpha: 0.1);
final bottomBlobColor = isDark
? primaryColor.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05);
return GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
behavior: HitTestBehavior.opaque,
child: Scaffold(
backgroundColor: backgroundColor,
body: Stack(
children: [
Positioned(
top: -100,
right: -100,
child: Container(
width: 300,
height: 300,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: topBlobColor,
),
),
),
Positioned(
bottom: -50,
left: -50,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: bottomBlobColor,
),
),
),
SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
),
);
},
),
),
],
),
),
);
}
}

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:introduction_screen/introduction_screen.dart'; import 'package:introduction_screen/introduction_screen.dart';
import 'package:lottie/lottie.dart'; import 'package:lottie/lottie.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class OnboardingView extends StatelessWidget { class OnboardingView extends StatelessWidget {
const OnboardingView({required this.callbackOnSuccess, super.key}); const OnboardingView({required this.callbackOnSuccess, super.key});
@ -53,19 +54,6 @@ class OnboardingView extends StatelessWidget {
), ),
), ),
), ),
// PageViewModel(
// title: context.lang.onboardingSendTwonliesTitle,
// body: context.lang.onboardingSendTwonliesBody,
// image: Center(
// child: Padding(
// padding: const EdgeInsets.only(top: 100),
// child: Lottie.asset(
// 'assets/animations/twonlies.lottie',
// repeat: false,
// ),
// ),
// ),
// ),
PageViewModel( PageViewModel(
title: context.lang.onboardingNotProductTitle, title: context.lang.onboardingNotProductTitle,
bodyWidget: Column( bodyWidget: Column(
@ -81,7 +69,7 @@ class OnboardingView extends StatelessWidget {
right: 50, right: 50,
top: 20, top: 20,
), ),
child: FilledButton( child: MyButton(
onPressed: callbackOnSuccess, onPressed: callbackOnSuccess,
child: Text(context.lang.registerSubmitButton), child: Text(context.lang.registerSubmitButton),
), ),
@ -97,17 +85,6 @@ class OnboardingView extends StatelessWidget {
), ),
), ),
), ),
// PageViewModel(
// title: context.lang.onboardingGetStartedTitle,
// image: Center(
// child: Padding(
// padding: const EdgeInsets.only(top: 100),
// child: Lottie.asset(
// 'assets/animations/rocket.lottie',
// ),
// ),
// ),
// ),
], ],
done: const Text(''), done: const Text(''),
next: Text(context.lang.next), next: Text(context.lang.next),

View file

@ -3,11 +3,11 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:restart_app/restart_app.dart'; import 'package:restart_app/restart_app.dart';
import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/themes/light.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart'; import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart';
import 'package:twonly/src/visual/views/onboarding/components/onboarding_wrapper.dart'; import 'package:twonly/src/visual/views/settings/backup/components/backup_setup.comp.dart';
class BackupRecoveryView extends StatefulWidget { class BackupRecoveryView extends StatefulWidget {
const BackupRecoveryView({super.key}); const BackupRecoveryView({super.key});
@ -64,180 +64,128 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
}); });
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = isDarkMode(context); final isDark = isDarkMode(context);
final cardColor = isDark ? const Color(0xFF1E293B) : Colors.white; final titleColor = isDark ? Colors.white : Colors.black87;
final inputColor = isDark ? const Color(0xFF0F172A) : Colors.grey[100]; final iconColor = isDark ? Colors.white70 : Colors.black54;
return OnboardingWrapper( return GestureDetector(
children: [ onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
Row( behavior: HitTestBehavior.opaque,
children: [ child: Scaffold(
IconButton( backgroundColor: Theme.of(context).scaffoldBackgroundColor,
onPressed: () => Navigator.of(context).pop(), body: SafeArea(
icon: const Icon( child: LayoutBuilder(
Icons.arrow_back_ios_new_rounded, builder: (context, constraints) {
), return SingleChildScrollView(
color: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 24),
iconSize: 20, child: ConstrainedBox(
), constraints: BoxConstraints(
const Spacer(), minHeight: constraints.maxHeight,
IconButton(
onPressed: () async {
await showAlertDialog(
context,
'twonly Backup',
context.lang.backupTwonlySafeLongDesc,
);
},
icon: const FaIcon(FontAwesomeIcons.circleInfo),
color: Colors.white,
iconSize: 20,
),
],
),
const SizedBox(height: 20),
const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: LinkLogoAnimation(),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
context.lang.twonlySafeRecoverTitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -0.5,
),
),
),
const SizedBox(height: 48),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: usernameCtrl,
onChanged: (value) => setState(() {}),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black,
),
decoration: InputDecoration(
hintText: context.lang.registerUsernameDecoration,
hintStyle: TextStyle(
color: isDark ? Colors.grey[500] : Colors.grey[600],
), ),
filled: true, child: IntrinsicHeight(
fillColor: inputColor, child: Column(
border: OutlineInputBorder( crossAxisAlignment: CrossAxisAlignment.stretch,
borderRadius: BorderRadius.circular(16), children: [
borderSide: BorderSide.none, Row(
), children: [
prefixIcon: Icon( IconButton(
Icons.alternate_email, onPressed: () => Navigator.of(context).pop(),
color: isDark ? Colors.grey[400] : Colors.grey[600], icon: const Icon(
), Icons.arrow_back_ios_new_rounded,
), ),
), color: iconColor,
const SizedBox(height: 16), iconSize: 20,
TextField( ),
controller: passwordCtrl, const Spacer(),
onChanged: (value) => setState(() {}), IconButton(
style: TextStyle( onPressed: () => showBackupExplanation(context),
fontSize: 18, icon: const FaIcon(FontAwesomeIcons.circleInfo),
fontWeight: FontWeight.w500, color: iconColor,
color: isDark ? Colors.white : Colors.black, iconSize: 20,
), ),
obscureText: obscureText, ],
decoration: InputDecoration( ),
hintText: context.lang.password, const SizedBox(height: 20),
hintStyle: TextStyle( Center(
color: isDark ? Colors.grey[500] : Colors.grey[600], child: Padding(
), padding: const EdgeInsets.all(20),
filled: true, child: LinkLogoAnimation(
fillColor: inputColor, color: isDark ? Colors.white : Colors.black,
border: OutlineInputBorder( ),
borderRadius: BorderRadius.circular(16), ),
borderSide: BorderSide.none, ),
), const SizedBox(height: 16),
prefixIcon: Icon( Padding(
Icons.lock_outline_rounded, padding: const EdgeInsets.symmetric(horizontal: 20),
color: isDark ? Colors.grey[400] : Colors.grey[600], child: Text(
), context.lang.twonlySafeRecoverTitle,
suffixIcon: IconButton( textAlign: TextAlign.center,
onPressed: () { style: TextStyle(
setState(() { fontSize: 24,
obscureText = !obscureText; fontWeight: FontWeight.w800,
}); color: titleColor,
}, letterSpacing: -0.5,
icon: FaIcon( ),
obscureText ),
? FontAwesomeIcons.eye ),
: FontAwesomeIcons.eyeSlash, const SizedBox(height: 48),
size: 16, MyInput(
color: isDark ? Colors.grey[400] : Colors.grey[600], controller: usernameCtrl,
onChanged: (value) => setState(() {}),
hintText: context.lang.registerUsernameDecoration,
prefixIcon: const Icon(Icons.alternate_email),
),
const SizedBox(height: 16),
MyInput(
controller: passwordCtrl,
onChanged: (value) => setState(() {}),
obscureText: obscureText,
hintText: context.lang.password,
prefixIcon: const Icon(Icons.lock_outline_rounded),
suffixIcon: IconButton(
onPressed: () {
setState(() {
obscureText = !obscureText;
});
},
icon: FaIcon(
obscureText
? FontAwesomeIcons.eye
: FontAwesomeIcons.eyeSlash,
size: 16,
),
),
),
const SizedBox(height: 32),
MyButton(
onPressed: (!isLoading) ? _recoverTwonlySafe : null,
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation(
Colors.white,
),
strokeWidth: 3,
),
)
: Text(context.lang.twonlySafeRecoverBtn),
),
const Spacer(),
const SizedBox(height: 40),
],
), ),
), ),
), ),
), );
const SizedBox(height: 32), },
FilledButton(
onPressed: (!isLoading) ? _recoverTwonlySafe : null,
style: FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(60),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
elevation: 0,
),
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation(Colors.white),
strokeWidth: 3,
),
)
: Text(
context.lang.twonlySafeRecoverBtn,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
],
), ),
), ),
const Spacer(), ),
const SizedBox(height: 40),
],
); );
} }
} }

View file

@ -17,10 +17,10 @@ import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/pow.dart'; import 'package:twonly/src/utils/pow.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';
import 'package:twonly/src/visual/themes/light.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
import 'package:twonly/src/visual/views/groups/group.view.dart'; import 'package:twonly/src/visual/views/groups/group.view.dart';
import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart'; import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart';
import 'package:twonly/src/visual/views/onboarding/components/onboarding_wrapper.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
class RegisterView extends StatefulWidget { class RegisterView extends StatefulWidget {
@ -43,8 +43,9 @@ class _RegisterViewState extends State<RegisterView> {
bool _registrationDisabled = false; bool _registrationDisabled = false;
bool _isTryingToRegister = false; bool _isTryingToRegister = false;
bool _isValidUserName = false; bool _isValidUserName = false;
bool _showUserNameError = false;
bool _showProofOfWorkError = false; bool _showProofOfWorkError = false;
String? _usernameErrorText;
late Future<int>? proofOfWork; late Future<int>? proofOfWork;
@ -58,7 +59,7 @@ class _RegisterViewState extends State<RegisterView> {
Future<void> createNewUser() async { Future<void> createNewUser() async {
if (!_isValidUserName) { if (!_isValidUserName) {
setState(() { setState(() {
_showUserNameError = true; _usernameErrorText = context.lang.registerUsernameLimits;
}); });
return; return;
} }
@ -67,278 +68,271 @@ class _RegisterViewState extends State<RegisterView> {
setState(() { setState(() {
_isTryingToRegister = true; _isTryingToRegister = true;
_showUserNameError = false; _usernameErrorText = null;
_showProofOfWorkError = false; _showProofOfWorkError = false;
}); });
late int proof; try {
late int proof;
if (proofOfWork != null) { if (proofOfWork != null) {
proof = await proofOfWork!; proof = await proofOfWork!;
} else { } else {
final (pow, registrationDisabled) = await apiService.getProofOfWork(); final (pow, registrationDisabled) = await apiService.getProofOfWork();
if (pow == null) { if (pow == null) {
_registrationDisabled = registrationDisabled; setState(() {
_registrationDisabled = registrationDisabled;
_isTryingToRegister = false;
});
if (mounted) {
showNetworkIssue(context);
}
return;
}
proof = await calculatePoW(pow.prefix, pow.difficulty.toInt());
}
Log.info('The result of the POW is $proof');
await createIfNotExistsSignalIdentity();
var userId = 0;
final res = await apiService.register(username, inviteCode, proof);
if (res.isSuccess) {
Log.info('Got user_id ${res.value} from server');
userId = res.value.userid.toInt() as int;
} else {
proofOfWork = null;
if (res.error == ErrorCode.RegistrationDisabled) {
setState(() {
_registrationDisabled = true;
_isTryingToRegister = false;
});
return;
}
if (res.error == ErrorCode.UserIdAlreadyTaken) {
Log.error('User ID already token. Tying again.');
await deleteLocalUserData();
return createNewUser();
}
if (res.error == ErrorCode.UsernameAlreadyTaken ||
res.error == ErrorCode.UsernameNotValid) {
setState(() {
_usernameErrorText = errorCodeToText(
context,
res.error as ErrorCode,
);
_isTryingToRegister = false;
});
return;
}
if (res.error == ErrorCode.InvalidProofOfWork) {
await deleteLocalUserData();
setState(() {
_showProofOfWorkError = true;
_isTryingToRegister = false;
});
return;
}
if (mounted) { if (mounted) {
showNetworkIssue(context); setState(() {
_isTryingToRegister = false;
});
await showAlertDialog(
context,
'Oh no!',
errorCodeToText(context, res.error as ErrorCode),
);
} }
return; return;
// Starting with the proof of work.
} }
proof = await calculatePoW(pow.prefix, pow.difficulty.toInt());
}
Log.info('The result of the POW is $proof'); setState(() {
_isTryingToRegister = false;
});
await createIfNotExistsSignalIdentity(); final userData = UserData(
userId: userId,
username: username,
displayName: username,
subscriptionPlan: 'Free',
currentSetupPage: SetupPages.profile.name,
appVersion: AppState.latestAppVersionId,
);
var userId = 0; await UserService.save(userData);
final res = await apiService.register(username, inviteCode, proof); await apiService.authenticate();
if (res.isSuccess) { widget.callbackOnSuccess();
Log.info('Got user_id ${res.value} from server'); } catch (e, stack) {
userId = res.value.userid.toInt() as int; Log.error('Error creating new user', e, stack);
} else {
proofOfWork = null;
if (res.error == ErrorCode.RegistrationDisabled) {
_registrationDisabled = true;
return;
}
if (res.error == ErrorCode.UserIdAlreadyTaken) {
Log.error('User ID already token. Tying again.');
await deleteLocalUserData();
return createNewUser();
}
if (res.error == ErrorCode.InvalidProofOfWork) {
await deleteLocalUserData();
setState(() {
_showProofOfWorkError = true;
_isTryingToRegister = false;
});
return;
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_isTryingToRegister = false; _isTryingToRegister = false;
}); });
await showAlertDialog( await showAlertDialog(
context, context,
'Oh no!', 'Error',
errorCodeToText(context, res.error as ErrorCode), e.toString(),
); );
} }
return;
} }
setState(() {
_isTryingToRegister = false;
});
final userData = UserData(
userId: userId,
username: username,
displayName: username,
subscriptionPlan: 'Free',
currentSetupPage: SetupPages.profile.name,
appVersion: AppState.latestAppVersionId,
);
await UserService.save(userData);
await apiService.authenticate();
widget.callbackOnSuccess();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = isDarkMode(context); final isDark = isDarkMode(context);
final cardColor = isDark ? const Color(0xFF1E293B) : Colors.white;
final inputColor = isDark ? const Color(0xFF0F172A) : Colors.grey[100];
final sloganColor = isDark ? Colors.white.withValues(alpha: 0.9) : Colors.grey[800];
final secondaryButtonColor = isDark ? Colors.grey[400] : Colors.grey[600];
return OnboardingWrapper( return GestureDetector(
children: [ onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
const SizedBox(height: 30), behavior: HitTestBehavior.opaque,
Center( child: Scaffold(
child: Container( backgroundColor: Theme.of(context).scaffoldBackgroundColor,
padding: const EdgeInsets.all(10), body: SafeArea(
child: const LinkLogoAnimation(), child: LayoutBuilder(
), builder: (context, constraints) {
), return SingleChildScrollView(
const SizedBox(height: 12), padding: const EdgeInsets.symmetric(horizontal: 24),
Padding( child: ConstrainedBox(
padding: const EdgeInsets.symmetric(horizontal: 20), constraints: BoxConstraints(
child: Text( minHeight: constraints.maxHeight,
context.lang.registerSlogan,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.white.withValues(alpha: 0.9),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 30),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: isDark ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_registrationDisabled) ...[
const SizedBox(height: 24),
Text(
context.lang.registrationClosed,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
color: Colors.red,
), ),
), child: IntrinsicHeight(
const SizedBox(height: 48), child: Column(
] else ...[ crossAxisAlignment: CrossAxisAlignment.stretch,
Text( children: [
context.lang.registerUsernameSlogan, const SizedBox(height: 30),
textAlign: TextAlign.center, Center(
style: TextStyle( child: Container(
fontSize: 16, padding: const EdgeInsets.all(10),
color: sloganColor, child: LinkLogoAnimation(
fontWeight: FontWeight.w600, color: isDark ? Colors.white : Colors.black,
), ),
),
const SizedBox(height: 20),
TextField(
controller: usernameController,
onChanged: (value) {
usernameController.text = value.toLowerCase();
usernameController.selection = TextSelection.fromPosition(
TextPosition(
offset: usernameController.text.length,
),
);
setState(() {
_isValidUserName = usernameController.text.length >= 3;
});
},
inputFormatters: [
LengthLimitingTextInputFormatter(12),
FilteringTextInputFormatter.allow(
RegExp('[a-z0-9A-Z._]'),
),
],
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black,
),
decoration: InputDecoration(
hintText: context.lang.registerUsernameDecoration,
hintStyle: TextStyle(
color: isDark ? Colors.grey[500] : Colors.grey[600],
),
filled: true,
fillColor: inputColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.alternate_email,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
),
),
if (_showUserNameError && usernameController.text.length < 3) ...[
const SizedBox(height: 8),
Text(
context.lang.registerUsernameLimits,
style: const TextStyle(
color: Colors.red,
fontSize: 13,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
if (_showProofOfWorkError) ...[
const SizedBox(height: 8),
Text(
context.lang.registerProofOfWorkFailed,
style: const TextStyle(
color: Colors.red,
fontSize: 13,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _isTryingToRegister ? null : createNewUser,
style: FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(60),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
elevation: 0,
),
child: _isTryingToRegister
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation(Colors.white),
strokeWidth: 3,
),
)
: Text(
context.lang.registerSubmitButton,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
), ),
), ),
), const SizedBox(height: 12),
const SizedBox(height: 16), Padding(
], padding: const EdgeInsets.symmetric(horizontal: 20),
TextButton( child: Text(
onPressed: () => context.push( context.lang.registerSlogan,
Routes.settingsBackupRecovery, textAlign: TextAlign.center,
), style: TextStyle(
style: TextButton.styleFrom( fontSize: 16,
minimumSize: const Size.fromHeight(50), color:
foregroundColor: secondaryButtonColor, Theme.of(context).textTheme.bodyMedium?.color
shape: RoundedRectangleBorder( ?.withValues(alpha: 0.7) ??
borderRadius: BorderRadius.circular(18), (isDark
? Colors.white.withValues(alpha: 0.7)
: Colors.black.withValues(alpha: 0.7)),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 40),
if (_registrationDisabled) ...[
Text(
context.lang.registrationClosed,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
color: Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 40),
] else ...[
Text(
context.lang.registerUsernameSlogan,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
color: isDark ? Colors.white : Colors.black87,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 24),
MyInput(
controller: usernameController,
errorText: _usernameErrorText,
onChanged: (value) {
usernameController.text = value.toLowerCase();
usernameController.selection =
TextSelection.fromPosition(
TextPosition(
offset: usernameController.text.length,
),
);
setState(() {
_isValidUserName =
usernameController.text.length >= 3;
_usernameErrorText = null;
});
},
inputFormatters: [
LengthLimitingTextInputFormatter(12),
FilteringTextInputFormatter.allow(
RegExp('[a-z0-9A-Z._]'),
),
],
hintText: context.lang.registerUsernameDecoration,
prefixIcon: const Icon(Icons.alternate_email),
),
if (_showProofOfWorkError) ...[
const SizedBox(height: 10),
Text(
context.lang.registerProofOfWorkFailed,
style: const TextStyle(
color: Colors.redAccent,
fontSize: 14,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 32),
MyButton(
onPressed: _isTryingToRegister
? null
: createNewUser,
child: _isTryingToRegister
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation(
Colors.white,
),
strokeWidth: 3,
),
)
: Text(
context.lang.registerSubmitButton,
),
),
const SizedBox(height: 20),
],
MyButton(
onPressed: () => context.push(
Routes.settingsBackupRecovery,
),
variant: MyButtonVariant.secondary,
child: Text(
context.lang.twonlySafeRecoverBtn,
),
),
const Spacer(),
const SizedBox(height: 40),
],
),
), ),
), ),
child: Text( );
context.lang.twonlySafeRecoverBtn, },
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
],
), ),
), ),
const Spacer(), ),
const SizedBox(height: 40),
],
); );
} }
} }

View file

@ -5,6 +5,7 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/services/profile.service.dart'; import 'package:twonly/src/services/profile.service.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/onboarding/setup/backup.setup.dart'; import 'package:twonly/src/visual/views/onboarding/setup/backup.setup.dart';
import 'package:twonly/src/visual/views/onboarding/setup/let_your_friends_find_you.setup.dart'; import 'package:twonly/src/visual/views/onboarding/setup/let_your_friends_find_you.setup.dart';
import 'package:twonly/src/visual/views/onboarding/setup/profile.setup.dart'; import 'package:twonly/src/visual/views/onboarding/setup/profile.setup.dart';
@ -152,7 +153,9 @@ class _SetupViewState extends State<SetupView> {
right: index == currentPage.totalPages - 1 ? 0 : 8, right: index == currentPage.totalPages - 1 ? 0 : 8,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isFinished ? context.color.primary : context.color.surfaceContainer, color: isFinished
? context.color.primary
: context.color.surfaceContainer,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
@ -175,35 +178,30 @@ class _SetupViewState extends State<SetupView> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (currentPage.index > 0) if (currentPage.index > 0)
TextButton( MyButton(
onPressed: () async { onPressed: () async {
await UserService.update((u) { await UserService.update((u) {
u.currentSetupPage = currentPage.previous()?.name; u.currentSetupPage = currentPage.previous()?.name;
}); });
}, },
variant: MyButtonVariant.text,
child: Text( child: Text(
context.lang.back, context.lang.back,
style: TextStyle(
color: context.color.primary,
fontWeight: FontWeight.bold,
),
), ),
), ),
if (currentPage.index > 0 && !currentPage.isLast) const SizedBox(width: 24), if (currentPage.index > 0 && !currentPage.isLast)
const SizedBox(width: 24),
if (!currentPage.isLast) if (!currentPage.isLast)
TextButton( MyButton(
onPressed: () async { onPressed: () async {
await UserService.update( await UserService.update(
(u) => u.skipSetupPages = true, (u) => u.skipSetupPages = true,
); );
widget.onUpdate?.call(); widget.onUpdate?.call();
}, },
variant: MyButtonVariant.text,
child: Text( child: Text(
context.lang.onboardingFinishLater, context.lang.onboardingFinishLater,
style: TextStyle(
color: context.color.primary,
fontWeight: FontWeight.bold,
),
), ),
), ),
], ],

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
class FinishSetupComp extends StatefulWidget { class FinishSetupComp extends StatefulWidget {
@ -123,29 +124,19 @@ class _FinishSetupCompState extends State<FinishSetupComp> {
), ),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
FilledButton.icon( MyButton(
onPressed: onTap, onPressed: onTap,
icon: const Icon( variant: MyButtonVariant.primaryDense,
Icons.arrow_forward_rounded, child: Row(
size: 18, mainAxisSize: MainAxisSize.min,
), children: [
label: Text( const Icon(
context.lang.finishSetupCardAction, Icons.arrow_forward_rounded,
style: const TextStyle( size: 18,
fontWeight: FontWeight.bold, ),
), const SizedBox(width: 8),
), Text(context.lang.finishSetupCardAction),
style: FilledButton.styleFrom( ],
backgroundColor: context.color.primary,
foregroundColor: context.color.onPrimary,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
), ),
), ),
], ],

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
class NextButtonComp extends StatelessWidget { class NextButtonComp extends StatelessWidget {
@ -24,7 +25,7 @@ class NextButtonComp extends StatelessWidget {
final currentPage = SetupPagesExtension.fromStr( final currentPage = SetupPagesExtension.fromStr(
userService.currentUser.currentSetupPage, userService.currentUser.currentSetupPage,
); );
return ElevatedButton( return MyButton(
onPressed: (canSubmit && !isLoading) onPressed: (canSubmit && !isLoading)
? () async { ? () async {
if (onPressed != null) { if (onPressed != null) {
@ -36,15 +37,6 @@ class NextButtonComp extends StatelessWidget {
}); });
} }
: null, : null,
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 56),
backgroundColor: context.color.primary,
foregroundColor: context.color.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: isLoading child: isLoading
? const SizedBox( ? const SizedBox(
height: 24, height: 24,
@ -56,7 +48,6 @@ class NextButtonComp extends StatelessWidget {
) )
: Text( : Text(
currentPage.isLast ? context.lang.finishSetup : context.lang.next, currentPage.isLast ? context.lang.finishSetup : context.lang.next,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
), ),
); );
}, },

View file

@ -1,13 +1,15 @@
import 'package:avatar_maker/avatar_maker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.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/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'
show AvatarIcon;
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
import 'package:twonly/src/visual/themes/light.dart';
import 'package:twonly/src/visual/views/onboarding/setup/components/next_button.comp.dart'; import 'package:twonly/src/visual/views/onboarding/setup/components/next_button.comp.dart';
import 'package:vector_graphics/vector_graphics.dart';
class ProfileSetupPage extends StatefulWidget { class ProfileSetupPage extends StatefulWidget {
const ProfileSetupPage({super.key}); const ProfileSetupPage({super.key});
@ -17,10 +19,7 @@ class ProfileSetupPage extends StatefulWidget {
} }
class _ProfileSetupPageState extends State<ProfileSetupPage> { class _ProfileSetupPageState extends State<ProfileSetupPage> {
final AvatarMakerController _avatarMakerController =
PersistentAvatarMakerController(customizedPropertyCategories: []);
late final TextEditingController _displayNameController; late final TextEditingController _displayNameController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -35,95 +34,86 @@ class _ProfileSetupPageState extends State<ProfileSetupPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return StreamBuilder(
mainAxisSize: MainAxisSize.min, stream: userService.onUserUpdated,
children: [ builder: (context, asyncSnapshot) {
Text( return Column(
context.lang.onboardingProfileTitle, mainAxisSize: MainAxisSize.min,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( children: [
fontWeight: FontWeight.bold, Text(
), context.lang.onboardingProfileTitle,
), style: Theme.of(context).textTheme.headlineSmall?.copyWith(
const SizedBox(height: 8), fontWeight: FontWeight.bold,
Text(
context.lang.onboardingProfileBody,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.color.onSurfaceVariant,
),
),
const SizedBox(height: 40),
StreamBuilder(
stream: userService.onUserUpdated,
builder: (context, asyncSnapshot) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: context.color.primary.withValues(alpha: 0.2),
width: 4,
),
), ),
child: userService.currentUser.avatarSvg == null
? ClipRRect(
borderRadius: BorderRadius.circular(80),
child: Container(
width: 160,
height: 160,
color: context.color.surfaceContainer,
child: const SvgPicture(
AssetBytesLoader(
'assets/images/default_avatar.svg.vec',
),
),
),
)
: AvatarMakerAvatar(
backgroundColor: context.color.surfaceContainer,
radius: 80,
controller: _avatarMakerController,
),
);
},
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () async {
await context.push(Routes.settingsProfileModifyAvatar);
await _avatarMakerController.performRestore();
},
icon: const Icon(Icons.palette_outlined),
label: Text(context.lang.settingsProfileCustomizeAvatar),
),
const SizedBox(height: 30),
TextField(
controller: _displayNameController,
decoration: InputDecoration(
labelText: context.lang.settingsProfileEditDisplayName,
hintText: context.lang.settingsProfileEditDisplayNameNew,
prefixIcon: const Icon(Icons.person_outline),
filled: true,
fillColor: context.color.surfaceContainerLow,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
), ),
floatingLabelBehavior: FloatingLabelBehavior.never, const SizedBox(height: 8),
), Text(
), context.lang.onboardingProfileBody,
const SizedBox(height: 40), textAlign: TextAlign.center,
NextButtonComp( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
onPressed: () async { color: context.color.onSurfaceVariant,
await UserService.update((user) { ),
if (_displayNameController.text.isNotEmpty) { ),
user.displayName = _displayNameController.text; const SizedBox(height: 40),
} StreamBuilder(
}); stream: userService.onUserUpdated,
return false; builder: (context, asyncSnapshot) {
}, return Container(
), padding: const EdgeInsets.all(4),
], clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(
shape: BoxShape.circle,
),
foregroundDecoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: primaryColor,
width: 4,
),
),
child: const AvatarIcon(
fontSize: 68,
myAvatar: true,
),
);
},
),
const SizedBox(height: 16),
MyButton(
onPressed: () async {
await context.push(Routes.settingsProfileModifyAvatar);
},
variant: MyButtonVariant.text,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.palette_outlined),
const SizedBox(width: 8),
Text(context.lang.settingsProfileCustomizeAvatar),
],
),
),
const SizedBox(height: 30),
MyInput(
controller: _displayNameController,
hintText: context.lang.settingsProfileEditDisplayNameNew,
prefixIcon: const Icon(Icons.person_outline),
),
const SizedBox(height: 40),
NextButtonComp(
onPressed: () async {
await UserService.update((user) {
if (_displayNameController.text.isNotEmpty) {
user.displayName = _displayNameController.text;
}
});
return false;
},
),
],
);
},
); );
} }
} }

View file

@ -7,6 +7,7 @@ import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/model/json/backup.model.dart'; import 'package:twonly/src/model/json/backup.model.dart';
import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class BackupView extends StatefulWidget { class BackupView extends StatefulWidget {
const BackupView({super.key}); const BackupView({super.key});
@ -176,7 +177,8 @@ class _BackupViewState extends State<BackupView> {
]), ]),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
OutlinedButton( MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: _isLoading onPressed: _isLoading
? null ? null
: () async { : () async {
@ -194,7 +196,8 @@ class _BackupViewState extends State<BackupView> {
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
Center( Center(
child: FilledButton( child: MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: () => onPressed: () =>
context.push(Routes.settingsBackupSetup, extra: true), context.push(Routes.settingsBackupSetup, extra: true),
child: Text( child: Text(

View file

@ -6,6 +6,7 @@ import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/backup/components/backup_setup.comp.dart'; import 'package:twonly/src/visual/views/settings/backup/components/backup_setup.comp.dart';
class SetupBackupView extends StatefulWidget { class SetupBackupView extends StatefulWidget {
@ -76,13 +77,7 @@ class _SetupBackupViewState extends State<SetupBackupView> {
title: const Text('twonly Backup'), title: const Text('twonly Backup'),
actions: [ actions: [
IconButton( IconButton(
onPressed: () async { onPressed: () => showBackupExplanation(context),
await showAlertDialog(
context,
'twonly Backup',
context.lang.backupTwonlySafeLongDesc,
);
},
icon: const FaIcon(FontAwesomeIcons.circleInfo), icon: const FaIcon(FontAwesomeIcons.circleInfo),
iconSize: 18, iconSize: 18,
), ),
@ -131,7 +126,8 @@ class _SetupBackupViewState extends State<SetupBackupView> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Center( Center(
child: FilledButton.icon( child: MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: onPressed:
(!_isLoading && (!_isLoading &&
(_passwordController.text == (_passwordController.text ==
@ -140,17 +136,26 @@ class _SetupBackupViewState extends State<SetupBackupView> {
!kReleaseMode)) !kReleaseMode))
? _updateBackupPassword ? _updateBackupPassword
: null, : null,
icon: _isLoading child: Row(
? const SizedBox( mainAxisSize: MainAxisSize.min,
children: [
if (_isLoading)
const SizedBox(
height: 12, height: 12,
width: 12, width: 12,
child: CircularProgressIndicator.adaptive(strokeWidth: 1), child: CircularProgressIndicator.adaptive(
strokeWidth: 1,
),
) )
: const Icon(Icons.lock_clock_rounded), else
label: Text( const Icon(Icons.lock_clock_rounded),
userService.currentUser.isBackupEnabled const SizedBox(width: 8),
? context.lang.backupEnableBackup Text(
: context.lang.backupChangePassword, userService.currentUser.isBackupEnabled
? context.lang.backupEnableBackup
: context.lang.backupChangePassword,
),
],
), ),
), ),
), ),

View file

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/services.dart' show rootBundle;
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
Future<bool> isSecurePassword(String password) async { Future<bool> isSecurePassword(String password) async {
final badPasswordsStr = await rootBundle.loadString( final badPasswordsStr = await rootBundle.loadString(
@ -46,29 +48,20 @@ class _BackupPasswordTextFieldState extends State<BackupPasswordTextField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextField( return MyInput(
controller: widget.controller, controller: widget.controller,
onChanged: widget.onChanged, onChanged: widget.onChanged,
obscureText: _obscureText, obscureText: _obscureText,
decoration: InputDecoration( hintText: widget.labelText,
labelText: widget.labelText, suffixIcon: IconButton(
filled: true, onPressed: () {
fillColor: context.color.surfaceContainerLow, setState(() {
border: OutlineInputBorder( _obscureText = !_obscureText;
borderRadius: BorderRadius.circular(16), });
borderSide: BorderSide.none, },
), icon: FaIcon(
floatingLabelBehavior: FloatingLabelBehavior.never, _obscureText ? FontAwesomeIcons.eye : FontAwesomeIcons.eyeSlash,
suffixIcon: IconButton( size: 16,
onPressed: () {
setState(() {
_obscureText = !_obscureText;
});
},
icon: FaIcon(
_obscureText ? FontAwesomeIcons.eye : FontAwesomeIcons.eyeSlash,
size: 16,
),
), ),
), ),
); );
@ -99,3 +92,68 @@ class PasswordRequirementText extends StatelessWidget {
); );
} }
} }
void showBackupExplanation(BuildContext context) {
final isDark = isDarkMode(context);
final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
final textColor = isDark ? Colors.white : Colors.black87;
final subtitleColor = isDark ? Colors.white70 : Colors.black54;
showModalBottomSheet<void>(
context: context,
backgroundColor: backgroundColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(28),
),
),
isScrollControlled: true,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: isDark ? Colors.white24 : Colors.black12,
borderRadius: BorderRadius.circular(2.5),
),
),
),
const SizedBox(height: 24),
Text(
'twonly Backup',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: textColor,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
context.lang.backupTwonlySafeLongDesc,
style: TextStyle(
fontSize: 16,
height: 1.5,
color: subtitleColor,
),
),
const SizedBox(height: 32),
MyButton(
onPressed: () => Navigator.pop(context),
child: const Text('Got it'),
),
],
),
),
);
},
);
}

View file

@ -297,8 +297,8 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
), ),
), ),
ListTile( ListTile(
title: const Text('User ID'), title: const Text('Informations'),
subtitle: Text(userService.currentUser.userId.toString()), onTap: () => context.push(Routes.settingsDeveloperInformations),
), ),
ListTile( ListTile(
title: const Text('Show Retransmission Database'), title: const Text('Show Retransmission Database'),
@ -343,24 +343,6 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
onChanged: (a) => toggleVideoStabilization(), onChanged: (a) => toggleVideoStabilization(),
), ),
), ),
ListTile(
title: const Text('Delete all (!) app data'),
onTap: () async {
final ok = await showAlertDialog(
context,
'Sure?',
'If you do not have a backup, you have to register with a new account.',
);
if (ok) {
await deleteLocalUserData();
await Restart.restartApp(
notificationTitle: 'Account successfully deleted',
notificationBody: 'Click here to open the app again',
forceKill: true,
);
}
},
),
ListTile( ListTile(
title: const Text('Reduce flames'), title: const Text('Reduce flames'),
onTap: () => context.push(Routes.settingsDeveloperReduceFlames), onTap: () => context.push(Routes.settingsDeveloperReduceFlames),
@ -400,7 +382,9 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
? const SizedBox( ? const SizedBox(
width: 24, width: 24,
height: 24, height: 24,
child: CircularProgressIndicator.adaptive(strokeWidth: 2), child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
) )
: null, : null,
onTap: _isGeneratingMockImages onTap: _isGeneratingMockImages
@ -415,6 +399,27 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
}); });
}, },
), ),
ListTile(
title: const Text(
'Delete all app data',
style: TextStyle(color: Colors.red),
),
onTap: () async {
final ok = await showAlertDialog(
context,
'Sure?',
'If you do not have a backup, you have to register with a new account.',
);
if (ok) {
await deleteLocalUserData();
await Restart.restartApp(
notificationTitle: 'Account successfully deleted',
notificationBody: 'Click here to open the app again',
forceKill: true,
);
}
},
),
], ],
); );
}, },

View file

@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
class DeveloperInformationsView extends StatefulWidget {
const DeveloperInformationsView({super.key});
@override
State<DeveloperInformationsView> createState() =>
_DeveloperInformationsViewState();
}
class _DeveloperInformationsViewState extends State<DeveloperInformationsView> {
String? _lastFcmTimestamp;
String? _lastServerTimestamp;
@override
void initState() {
super.initState();
_loadInformations();
}
Future<void> _loadInformations({bool showFeedback = false}) async {
const storage = FlutterSecureStorage();
try {
final lastFcm = await storage.read(
key: SecureStorageKeys.lastFcmMessageTimestamp,
iOptions: const IOSOptions(
groupId: 'CN332ZUGRP.eu.twonly.shared',
accessibility: KeychainAccessibility.first_unlock,
),
);
final lastServer = await storage.read(
key: SecureStorageKeys.lastServerMessageTimestamp,
iOptions: const IOSOptions(
groupId: 'CN332ZUGRP.eu.twonly.shared',
accessibility: KeychainAccessibility.first_unlock,
),
);
if (mounted) {
setState(() {
_lastFcmTimestamp = lastFcm;
_lastServerTimestamp = lastServer;
});
if (showFeedback) {
showSnackbar(
context,
'Developer information loaded',
level: SnackbarLevel.success,
);
}
}
} catch (_) {}
}
String _formatTimestamp(String? timestampStr) {
if (timestampStr == null) return 'Never';
final ms = int.tryParse(timestampStr);
if (ms == null) return 'Invalid: $timestampStr';
final dt = DateTime.fromMillisecondsSinceEpoch(ms);
return dt.toLocal().toString();
}
@override
Widget build(BuildContext context) {
final userId = userService.currentUser.userId.toString();
return Scaffold(
appBar: AppBar(
title: const Text('Informations'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => _loadInformations(showFeedback: true),
),
],
),
body: ListView(
children: [
ListTile(
title: const Text('User ID'),
subtitle: Text(userId),
trailing: IconButton(
icon: const Icon(Icons.copy),
onPressed: () {
Clipboard.setData(ClipboardData(text: userId));
showSnackbar(context, 'User ID copied to clipboard');
},
),
),
const Divider(),
ListTile(
title: const Text('Last FCM Message'),
subtitle: Text(_formatTimestamp(_lastFcmTimestamp)),
),
ListTile(
title: const Text('Last Server Message'),
subtitle: Text(_formatTimestamp(_lastServerTimestamp)),
),
],
),
);
}
}

View file

@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:fixnum/fixnum.dart'; import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -12,6 +15,7 @@ import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/utils/log.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/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/help/contact_us/submit_message.view.dart'; import 'package:twonly/src/visual/views/settings/help/contact_us/submit_message.view.dart';
import 'package:twonly/src/visual/views/settings/help/faq.view.dart'; import 'package:twonly/src/visual/views/settings/help/faq.view.dart';
@ -29,13 +33,29 @@ class _ContactUsState extends State<ContactUsView> {
int? _selectedFeedback; int? _selectedFeedback;
String? _selectedReason; String? _selectedReason;
String? debugLogDownloadToken; String? debugLogDownloadToken;
String? debugLogEncryptionKey;
Future<String?> uploadDebugLog() async { Future<(String, String)?> uploadDebugLog() async {
if (debugLogDownloadToken != null) return debugLogDownloadToken; if (debugLogDownloadToken != null && debugLogEncryptionKey != null) {
return (debugLogDownloadToken!, debugLogEncryptionKey!);
}
final downloadToken = getRandomUint8List(32); final downloadToken = getRandomUint8List(32);
final encryptionKey = getRandomUint8List(32);
final debugLog = await loadLogFile(); final debugLog = await loadLogFile();
// 1. Compress the debug log
final logBytes = utf8.encode(debugLog);
final compressedBytes = gzip.encode(logBytes);
// 2. Encrypt using AES-GCM (with 256 bits)
final algorithm = AesGcm.with256bits();
final secretBox = await algorithm.encrypt(
compressedBytes,
secretKey: SecretKey(encryptionKey),
);
final encryptedData = secretBox.concatenation();
final messageOnSuccess = TextMessage() final messageOnSuccess = TextMessage()
..body = [] ..body = []
..userId = Int64(); ..userId = Int64();
@ -43,7 +63,7 @@ class _ContactUsState extends State<ContactUsView> {
final uploadRequest = UploadRequest( final uploadRequest = UploadRequest(
messagesOnSuccess: [messageOnSuccess], messagesOnSuccess: [messageOnSuccess],
downloadTokens: [downloadToken], downloadTokens: [downloadToken],
encryptedData: debugLog.codeUnits, encryptedData: encryptedData,
); );
final uploadRequestBytes = uploadRequest.writeToBuffer(); final uploadRequestBytes = uploadRequest.writeToBuffer();
@ -71,10 +91,13 @@ class _ContactUsState extends State<ContactUsView> {
final response = await requestMultipart.send(); final response = await requestMultipart.send();
if (response.statusCode == 200) { if (response.statusCode == 200) {
final tokenHex = uint8ListToHex(downloadToken);
final keyHex = uint8ListToHex(encryptionKey);
setState(() { setState(() {
debugLogDownloadToken = uint8ListToHex(downloadToken); debugLogDownloadToken = tokenHex;
debugLogEncryptionKey = keyHex;
}); });
return debugLogDownloadToken; return (tokenHex, keyHex);
} }
return null; return null;
} }
@ -108,13 +131,13 @@ class _ContactUsState extends State<ContactUsView> {
} }
if (includeDebugLog) { if (includeDebugLog) {
String? token; (String, String)? result;
try { try {
token = await uploadDebugLog(); result = await uploadDebugLog();
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);
} }
if (token == null) { if (result == null) {
if (!mounted) return null; if (!mounted) return null;
showSnackbar(context, 'Could not upload the debug log!'); showSnackbar(context, 'Could not upload the debug log!');
setState(() { setState(() {
@ -122,7 +145,10 @@ class _ContactUsState extends State<ContactUsView> {
}); });
return null; return null;
} }
debugLogToken = 'Debug Log: https://api.twonly.eu/api/download/$token'; final downloadToken = result.$1;
final encryptionKey = result.$2;
debugLogToken =
'Debug Log: https://logs.twonly.eu#$downloadToken/$encryptionKey';
} }
setState(() { setState(() {
@ -238,17 +264,8 @@ $debugLogToken
), ),
), ),
), ),
ElevatedButton.icon( MyButton(
icon: isLoading variant: MyButtonVariant.primaryDense,
? SizedBox(
height: 12,
width: 12,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary),
),
)
: const FaIcon(FontAwesomeIcons.angleRight),
onPressed: isLoading onPressed: isLoading
? null ? null
: () async { : () async {
@ -263,7 +280,24 @@ $debugLogToken
Navigator.pop(context); Navigator.pop(context);
} }
}, },
label: Text(context.lang.next), child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading)
const SizedBox(
height: 12,
width: 12,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.black87),
),
)
else
const FaIcon(FontAwesomeIcons.angleRight, size: 14),
const SizedBox(width: 8),
Text(context.lang.next),
],
),
), ),
], ],
), ),

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class SubmitMessage extends StatefulWidget { class SubmitMessage extends StatefulWidget {
const SubmitMessage({required this.fullMessage, super.key}); const SubmitMessage({required this.fullMessage, super.key});
@ -100,7 +101,8 @@ class _ContactUsState extends State<SubmitMessage> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
ElevatedButton( MyButton(
variant: MyButtonVariant.primaryDense,
onPressed: isLoading ? null : _submitFeedback, onPressed: isLoading ? null : _submitFeedback,
child: Text(context.lang.submit), child: Text(context.lang.submit),
), ),

View file

@ -108,7 +108,8 @@ class UserDiscoverySetupComp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showShareYourFriends = final showShareYourFriends =
showOnlySpecificPage == UserDiscoveryPages.all || showOnlySpecificPage == UserDiscoveryPages.shareYourFriends; showOnlySpecificPage == UserDiscoveryPages.all ||
showOnlySpecificPage == UserDiscoveryPages.shareYourFriends;
final showLetYourFriendsFindYou = final showLetYourFriendsFindYou =
showOnlySpecificPage == UserDiscoveryPages.all || showOnlySpecificPage == UserDiscoveryPages.all ||
showOnlySpecificPage == UserDiscoveryPages.letYourFriendsFindYou; showOnlySpecificPage == UserDiscoveryPages.letYourFriendsFindYou;
@ -336,7 +337,9 @@ class UserDiscoverySetupComp extends StatelessWidget {
), ),
), ),
subtitle: Text( subtitle: Text(
context.lang.userDiscoverySettingsManualApprovalDesc, context
.lang
.userDiscoverySettingsManualApprovalDesc,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: context.color.onSurfaceVariant, color: context.color.onSurfaceVariant,
@ -350,13 +353,16 @@ class UserDiscoverySetupComp extends StatelessWidget {
), ),
], ],
), ),
crossFadeState: state.isUserDiscoveryEnabled ? CrossFadeState.showSecond : CrossFadeState.showFirst, crossFadeState: state.isUserDiscoveryEnabled
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
), ),
], ],
), ),
), ),
if (showOnlySpecificPage == UserDiscoveryPages.all) const SizedBox(height: 48), if (showOnlySpecificPage == UserDiscoveryPages.all)
const SizedBox(height: 48),
], ],
if (showLetYourFriendsFindYou) ...[ if (showLetYourFriendsFindYou) ...[
Text( Text(
@ -587,7 +593,9 @@ class UserDiscoverySetupComp extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
context.lang.userDiscoverySettingsMutualFriends, context
.lang
.userDiscoverySettingsMutualFriends,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -603,9 +611,10 @@ class UserDiscoverySetupComp extends StatelessWidget {
color: context.color.surface, color: context.color.surface,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: context.color.outlineVariant.withValues( color: context.color.outlineVariant
alpha: 0.5, .withValues(
), alpha: 0.5,
),
), ),
), ),
child: DropdownButtonHideUnderline( child: DropdownButtonHideUnderline(
@ -616,9 +625,9 @@ class UserDiscoverySetupComp extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
items: List.generate( items: List.generate(
9, 8,
(index) { (index) {
final value = index + 2; final value = index + 3;
return DropdownMenuItem<int>( return DropdownMenuItem<int>(
value: value, value: value,
child: Text('$value'), child: Text('$value'),
@ -640,7 +649,9 @@ class UserDiscoverySetupComp extends StatelessWidget {
), ),
], ],
), ),
crossFadeState: state.sharePromotion ? CrossFadeState.showSecond : CrossFadeState.showFirst, crossFadeState: state.sharePromotion
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
), ),
], ],

View file

@ -26,7 +26,7 @@ class _UserDiscoverySettingsViewState extends State<UserDiscoverySettingsView> {
isUserDiscoveryEnabled: u.isUserDiscoveryEnabled, isUserDiscoveryEnabled: u.isUserDiscoveryEnabled,
sharePromotion: u.userDiscoverySharePromotion, sharePromotion: u.userDiscoverySharePromotion,
isManualApprovalEnabled: u.userDiscoveryRequiresManualApproval, isManualApprovalEnabled: u.userDiscoveryRequiresManualApproval,
threshold: u.userDiscoveryThreshold, threshold: u.userDiscoveryThreshold < 3 ? 3 : u.userDiscoveryThreshold,
); );
} }

View file

@ -6,6 +6,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class ModifyAvatarView extends StatefulWidget { class ModifyAvatarView extends StatefulWidget {
const ModifyAvatarView({super.key}); const ModifyAvatarView({super.key});
@ -15,12 +16,19 @@ class ModifyAvatarView extends StatefulWidget {
} }
class _ModifyAvatarViewState extends State<ModifyAvatarView> { class _ModifyAvatarViewState extends State<ModifyAvatarView> {
final AvatarMakerController _avatarMakerController = late final _CustomAvatarMakerController _avatarMakerController;
PersistentAvatarMakerController(customizedPropertyCategories: []);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final svg = userService.currentUser.avatarSvg;
if (svg != null && svg.isNotEmpty) {
_avatarMakerController = _CustomAvatarMakerController(
svg: svg,
);
} else {
_avatarMakerController = _CustomAvatarMakerController.defaultAvatar();
}
} }
Future<void> updateUserAvatar(String json, String svg) async { Future<void> updateUserAvatar(String json, String svg) async {
@ -33,49 +41,38 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
} }
AvatarMakerThemeData getAvatarMakerTheme(BuildContext context) { AvatarMakerThemeData getAvatarMakerTheme(BuildContext context) {
if (isDarkMode(context)) { final colors = context.color;
return AvatarMakerThemeData( final isDark = isDarkMode(context);
boxDecoration: const BoxDecoration( return AvatarMakerThemeData(
boxShadow: [BoxShadow()], boxDecoration: BoxDecoration(
), color: colors.surface,
unselectedTileDecoration: BoxDecoration( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
color: const Color.fromARGB(255, 50, 50, 50), // Dark mode color boxShadow: [
borderRadius: BorderRadius.circular(10), BoxShadow(
), color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.05),
selectedTileDecoration: BoxDecoration( blurRadius: 10,
color: const Color.fromARGB(255, 100, 100, 100), // Dark mode color offset: const Offset(0, -5),
borderRadius: BorderRadius.circular(10), ),
), ],
selectedIconColor: Colors.white, ),
unselectedIconColor: Colors.grey, unselectedTileDecoration: BoxDecoration(
primaryBgColor: Colors.black, // Dark mode background color: colors.surfaceContainerHigh,
secondaryBgColor: Colors.grey[850], // Dark mode secondary background borderRadius: BorderRadius.circular(12),
labelTextStyle: const TextStyle( ),
color: Colors.white, selectedTileDecoration: BoxDecoration(
), // Light text for dark mode color: colors.primary.withValues(alpha: 0.15),
); border: Border.all(color: colors.primary, width: 2),
} else { borderRadius: BorderRadius.circular(12),
return AvatarMakerThemeData( ),
boxDecoration: const BoxDecoration( selectedIconColor: colors.primary,
boxShadow: [BoxShadow()], unselectedIconColor: colors.onSurfaceVariant.withValues(alpha: 0.6),
), primaryBgColor: colors.surface,
unselectedTileDecoration: BoxDecoration( secondaryBgColor: colors.surfaceContainerLow,
color: const Color.fromARGB(255, 240, 240, 240), // Light mode color labelTextStyle: TextStyle(
borderRadius: BorderRadius.circular(10), color: colors.onSurface,
), fontWeight: FontWeight.bold,
selectedTileDecoration: BoxDecoration( ),
color: const Color.fromARGB(255, 200, 200, 200), // Light mode color );
borderRadius: BorderRadius.circular(10),
),
selectedIconColor: Colors.black,
unselectedIconColor: Colors.grey,
primaryBgColor: Colors.white, // Light mode background
secondaryBgColor: Colors.grey[200], // Light mode secondary background
labelTextStyle: const TextStyle(
color: Colors.black,
), // Dark text for light mode
);
}
} }
Future<bool?> _showBackDialog() { Future<bool?> _showBackDialog() {
@ -148,39 +145,64 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
controller: _avatarMakerController, controller: _avatarMakerController,
), ),
), ),
SizedBox( Padding(
child: Row( padding: const EdgeInsets.symmetric(horizontal: 12),
mainAxisAlignment: MainAxisAlignment.center, child: Wrap(
spacing: 12,
runSpacing: 10,
alignment: WrapAlignment.center,
children: [ children: [
IconButton( MyButton(
icon: const FaIcon(FontAwesomeIcons.floppyDisk), variant: MyButtonVariant.primaryDense,
onPressed: storeAvatarAndExit, onPressed: storeAvatarAndExit,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(FontAwesomeIcons.floppyDisk, size: 16),
const SizedBox(width: 6),
Text(context.lang.avatarSaveChangesStore),
],
),
), ),
IconButton( MyButton(
icon: const FaIcon(FontAwesomeIcons.shuffle), variant: MyButtonVariant.secondaryDense,
onPressed: onPressed:
_avatarMakerController.randomizedSelectedOptions, _avatarMakerController.randomizedSelectedOptions,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(FontAwesomeIcons.shuffle, size: 16),
const SizedBox(width: 6),
Text(context.lang.avatarCustomizeRandomize),
],
),
), ),
IconButton( MyButton(
icon: const FaIcon(FontAwesomeIcons.rotateLeft), variant: MyButtonVariant.secondaryDense,
onLongPress: () async {
await PersistentAvatarMakerController.clearAvatarMaker();
await _avatarMakerController.restoreState();
},
onPressed: _avatarMakerController.restoreState, onPressed: _avatarMakerController.restoreState,
onLongPress: () {
_avatarMakerController.clearCustomizations();
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(FontAwesomeIcons.rotateLeft, size: 16),
const SizedBox(width: 6),
Text(context.lang.avatarCustomizeReset),
],
),
), ),
], ],
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 30, vertical: 30,
), ),
child: AvatarMakerCustomizer( child: AvatarMakerCustomizer(
scaffoldWidth: min( scaffoldWidth: min(
600, 600,
MediaQuery.of(context).size.width * 0.85, MediaQuery.of(context).size.width * 0.95,
), ),
theme: getAvatarMakerTheme(context), theme: getAvatarMakerTheme(context),
controller: _avatarMakerController, controller: _avatarMakerController,
@ -194,3 +216,72 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
); );
} }
} }
class _CustomAvatarMakerController extends NonPersistentAvatarMakerController {
_CustomAvatarMakerController({
required super.svg,
}) : _initialSvg = svg,
super.fromSvg() {
_initialOptions = Map.from(selectedOptions);
}
_CustomAvatarMakerController.defaultAvatar() : _initialSvg = '', super() {
_initialOptions = Map.from(defaultSelectedOptions);
}
final String _initialSvg;
late final Map<PropertyCategoryIds, PropertyItem> _initialOptions;
List<CustomizedPropertyCategory>? _customPropertyCategories;
void clearCustomizations() {
selectedOptions = Map.from(defaultSelectedOptions);
updatePreview();
}
@override
List<CustomizedPropertyCategory> get propertyCategories {
var list = _customPropertyCategories;
if (list == null) {
list = super.propertyCategories.map((category) {
return CustomizedPropertyCategory(
id: category.id,
name: category.name,
iconFile: category.iconFile,
properties: category.properties,
defaultValue: category.defaultValue,
);
}).toList();
_customPropertyCategories = list;
}
return list;
}
@override
List<CustomizedPropertyCategory> get displayedPropertyCategories {
final order = [
PropertyCategoryIds.SkinColor,
PropertyCategoryIds.EyeType,
PropertyCategoryIds.EyebrowType,
PropertyCategoryIds.Nose,
PropertyCategoryIds.MouthType,
PropertyCategoryIds.HairStyle,
PropertyCategoryIds.HairColor,
PropertyCategoryIds.FacialHairType,
PropertyCategoryIds.FacialHairColor,
PropertyCategoryIds.OutfitType,
PropertyCategoryIds.OutfitColor,
PropertyCategoryIds.Accessory,
];
return (propertyCategories.where((c) => order.contains(c.id)).toList()
..sort((a, b) => order.indexOf(a.id).compareTo(order.indexOf(b.id))));
}
@override
Future<RestoredData> performRestore() async {
final restoredSvg = _initialSvg.isNotEmpty ? _initialSvg : drawAvatarSVG();
return RestoredData(
svg: restoredSvg,
options: Map.from(_initialOptions),
);
}
}

View file

@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:avatar_maker/avatar_maker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
@ -10,8 +9,10 @@ import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/better_list_title.element.dart'; import 'package:twonly/src/visual/elements/better_list_title.element.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/groups/group.view.dart'; import 'package:twonly/src/visual/views/groups/group.view.dart';
class ProfileView extends StatefulWidget { class ProfileView extends StatefulWidget {
@ -22,9 +23,6 @@ class ProfileView extends StatefulWidget {
} }
class _ProfileViewState extends State<ProfileView> { class _ProfileViewState extends State<ProfileView> {
final AvatarMakerController _avatarMakerController =
PersistentAvatarMakerController(customizedPropertyCategories: []);
int twonlyScore = 0; int twonlyScore = 0;
late StreamSubscription<int> twonlyScoreSub; late StreamSubscription<int> twonlyScoreSub;
@ -104,22 +102,24 @@ class _ProfileViewState extends State<ProfileView> {
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
children: <Widget>[ children: <Widget>[
const SizedBox(height: 25), const SizedBox(height: 25),
AvatarMakerAvatar( const AvatarIcon(
backgroundColor: Colors.transparent, fontSize: 70,
radius: 80, myAvatar: true,
controller: _avatarMakerController,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Center( Center(
child: SizedBox( child: MyButton(
height: 35, variant: MyButtonVariant.secondaryDense,
child: ElevatedButton.icon( onPressed: () async {
icon: const Icon(Icons.edit), await context.push(Routes.settingsProfileModifyAvatar);
label: Text(context.lang.settingsProfileCustomizeAvatar), },
onPressed: () async { child: Row(
await context.push(Routes.settingsProfileModifyAvatar); mainAxisSize: MainAxisSize.min,
await _avatarMakerController.performRestore(); children: [
}, const Icon(Icons.edit, size: 16),
const SizedBox(width: 8),
Text(context.lang.settingsProfileCustomizeAvatar),
],
), ),
), ),
), ),

View file

@ -794,26 +794,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_secure_storage name: flutter_secure_storage
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.0" version: "10.3.1"
flutter_secure_storage_darwin: flutter_secure_storage_darwin:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_darwin name: flutter_secure_storage_darwin
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.0" version: "0.3.2"
flutter_secure_storage_linux: flutter_secure_storage_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_linux name: flutter_secure_storage_linux
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.1"
flutter_secure_storage_platform_interface: flutter_secure_storage_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -826,10 +826,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_web name: flutter_secure_storage_web
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.1"
flutter_secure_storage_windows: flutter_secure_storage_windows:
dependency: transitive dependency: transitive
description: description:
@ -1303,10 +1303,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@ -1946,10 +1946,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.11"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:

View file

@ -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.26+135 version: 0.2.29+138
environment: environment:
sdk: ^3.11.0 sdk: ^3.11.0
@ -94,7 +94,7 @@ dependencies:
archive: ^4.0.7 # 6.5 mio archive: ^4.0.7 # 6.5 mio
file_picker: ^10.3.6 # 2 mio file_picker: ^10.3.6 # 2 mio
get: ^4.7.2 # 740 k get: ^4.7.2 # 740 k
flutter_secure_storage: ^10.0.0 # 1.85 mio flutter_secure_storage: ^10.3.1 # 1.85 mio
permission_handler: ^12.0.0+1 # 2 mio permission_handler: ^12.0.0+1 # 2 mio
# Not yet checked # Not yet checked
@ -206,6 +206,9 @@ flutter_launcher_icons:
flutter: flutter:
uses-material-design: true uses-material-design: true
# config:
# enable-swift-package-manager: false
# Enable generation of localized Strings from arb files. # Enable generation of localized Strings from arb files.
generate: true generate: true

View file

@ -7,9 +7,16 @@ use protocols::user_discovery::traits::{AnnouncedUser, OtherPromotion};
use crate::error::{Result, TwonlyError}; use crate::error::{Result, TwonlyError};
use crate::{callback_generator, frb_generated::StreamSink}; use crate::{callback_generator, frb_generated::StreamSink};
use std::sync::{Arc, OnceLock}; use std::sync::Arc;
static FLUTTER_CALLBACKS: OnceLock<FlutterCallbacks> = OnceLock::new(); use std::collections::HashMap;
tokio::task_local! {
pub(crate) static CURRENT_CALLBACK_ID: u32;
}
pub(crate) static FLUTTER_CALLBACKS: std::sync::RwLock<Option<HashMap<u32, FlutterCallbacks>>> =
std::sync::RwLock::new(None);
// This will also generate the function init_flutter_callbacks which MUST be called from Flutter to initialize the callbacks // This will also generate the function init_flutter_callbacks which MUST be called from Flutter to initialize the callbacks
callback_generator! { callback_generator! {
@ -39,8 +46,25 @@ callback_generator! {
} }
} }
pub(crate) fn get_callbacks() -> Result<&'static FlutterCallbacks> { pub(crate) fn get_callbacks() -> Result<FlutterCallbacks> {
FLUTTER_CALLBACKS let caller_opt = CURRENT_CALLBACK_ID.try_with(|&c| c).ok();
.get()
.ok_or(TwonlyError::MissingCallbackInitialization) let lock = FLUTTER_CALLBACKS.read().unwrap();
let map = lock.as_ref().ok_or(TwonlyError::MissingCallbackInitialization)?;
if let Some(id) = caller_opt {
if let Some(cb) = map.get(&id) {
return Ok(cb.clone());
}
}
// Fallback: if not in a scoped tokio task or if the specific callback_id isn't found,
// we pick the first available callbacks from the map. This gracefully handles
// tracing initialization which happens outside of any scoped task.
if let Some((_, cb)) = map.iter().next() {
tracing::error!("FlutterCallbacks fallback used: No CURRENT_CALLBACK_ID scope was found, or the ID was missing from the map. Using an arbitrary callback. This may lead to race conditions if multiple isolates are active.");
return Ok(cb.clone());
}
Err(TwonlyError::MissingCallbackInitialization)
} }

View file

@ -13,6 +13,7 @@ macro_rules! callback_generator {
) => { ) => {
// 1. Generate the Nested Sub-Structs // 1. Generate the Nested Sub-Structs
$( $(
#[derive(Clone)]
pub(crate) struct $sub_struct_name { pub(crate) struct $sub_struct_name {
$( $(
pub(crate) $fn_name: Arc<dyn Fn($($input),*) -> DartFnFuture<$output> + Send + Sync + 'static>, pub(crate) $fn_name: Arc<dyn Fn($($input),*) -> DartFnFuture<$output> + Send + Sync + 'static>,
@ -21,6 +22,7 @@ macro_rules! callback_generator {
)* )*
// 2. Generate the Main Container Struct // 2. Generate the Main Container Struct
#[derive(Clone)]
pub(crate) struct $struct_name { pub(crate) struct $struct_name {
$( $(
pub(crate) $sub_struct_field: $sub_struct_name, pub(crate) $sub_struct_field: $sub_struct_name,
@ -30,6 +32,7 @@ macro_rules! callback_generator {
// 3. Generate the Automated Init Function // 3. Generate the Automated Init Function
paste::paste! { paste::paste! {
pub fn init_flutter_callbacks( pub fn init_flutter_callbacks(
callback_id: u32,
$( $(
$( $(
// Parameters: sub-struct_field + _ + fn_name // Parameters: sub-struct_field + _ + fn_name
@ -47,10 +50,11 @@ macro_rules! callback_generator {
)* )*
}; };
// Use the static global strictly named FLUTTER_CALLBACKS let mut lock = FLUTTER_CALLBACKS.write().unwrap();
FLUTTER_CALLBACKS.set(callbacks).unwrap_or_else(|_| { if lock.is_none() {
println!("Callbacks were already initialized!"); *lock = Some(std::collections::HashMap::new());
}); }
lock.as_mut().unwrap().insert(callback_id, callbacks);
} }
} }
}; };

View file

@ -1,3 +1,4 @@
use crate::bridge::callbacks::CURRENT_CALLBACK_ID;
use crate::bridge::get_twonly_flutter; use crate::bridge::get_twonly_flutter;
use crate::error::Result; use crate::error::Result;
@ -5,78 +6,95 @@ pub struct FlutterUserDiscovery {}
impl FlutterUserDiscovery { impl FlutterUserDiscovery {
pub async fn initialize_or_update( pub async fn initialize_or_update(
callback_id: u32,
threshold: u8, threshold: u8,
user_id: i64, user_id: i64,
public_key: Vec<u8>, public_key: Vec<u8>,
share_promotion: bool, share_promotion: bool,
) -> Result<()> { ) -> Result<()> {
tracing::info!("Rust bridge: initialize_or_update started"); CURRENT_CALLBACK_ID.scope(callback_id, async move {
let twonly = get_twonly_flutter()?; tracing::info!("Rust bridge: initialize_or_update started");
tracing::info!("Rust bridge: getting user_discovery lock"); let twonly = get_twonly_flutter()?;
let user_discovery = twonly.user_discovery.get().await; tracing::info!("Rust bridge: getting user_discovery lock");
tracing::info!("Rust bridge: calling initialize_or_update on protocols"); let user_discovery = twonly.user_discovery.get().await;
let res = user_discovery tracing::info!("Rust bridge: calling initialize_or_update on protocols");
.initialize_or_update(threshold, user_id, public_key, share_promotion) let res = user_discovery
.await; .initialize_or_update(threshold, user_id, public_key, share_promotion)
tracing::info!("Rust bridge: initialize_or_update on protocols finished"); .await;
Ok(res?) tracing::info!("Rust bridge: initialize_or_update on protocols finished");
Ok(res?)
}).await
} }
pub async fn get_current_version() -> Result<Vec<u8>> { pub async fn get_current_version(callback_id: u32) -> Result<Vec<u8>> {
Ok(get_twonly_flutter()? CURRENT_CALLBACK_ID.scope(callback_id, async move {
.user_discovery Ok(get_twonly_flutter()?
.get() .user_discovery
.await .get()
.get_current_version() .await
.await?) .get_current_version()
.await?)
}).await
} }
pub async fn get_new_messages( pub async fn get_new_messages(
callback_id: u32,
contact_id: i64, contact_id: i64,
received_version: &[u8], received_version: &[u8],
) -> Result<Vec<Vec<u8>>> { ) -> Result<Vec<Vec<u8>>> {
Ok(get_twonly_flutter()? CURRENT_CALLBACK_ID.scope(callback_id, async move {
.user_discovery Ok(get_twonly_flutter()?
.get() .user_discovery
.await .get()
.get_new_messages(contact_id, received_version) .await
.await?) .get_new_messages(contact_id, received_version)
.await?)
}).await
} }
pub async fn should_request_new_messages( pub async fn should_request_new_messages(
callback_id: u32,
contact_id: i64, contact_id: i64,
version: &[u8], version: &[u8],
) -> Result<Option<Vec<u8>>> { ) -> Result<Option<Vec<u8>>> {
Ok(get_twonly_flutter()? CURRENT_CALLBACK_ID.scope(callback_id, async move {
.user_discovery Ok(get_twonly_flutter()?
.get() .user_discovery
.await .get()
.should_request_new_messages(contact_id, version) .await
.await?) .should_request_new_messages(contact_id, version)
.await?)
}).await
} }
pub async fn handle_new_messages( pub async fn handle_new_messages(
callback_id: u32,
contact_id: i64, contact_id: i64,
public_key_verified_timestamp: Option<i64>, public_key_verified_timestamp: Option<i64>,
messages: Vec<Vec<u8>>, messages: Vec<Vec<u8>>,
) -> Result<()> { ) -> Result<()> {
Ok(get_twonly_flutter()? CURRENT_CALLBACK_ID.scope(callback_id, async move {
.user_discovery Ok(get_twonly_flutter()?
.get() .user_discovery
.await .get()
.handle_new_messages(contact_id, public_key_verified_timestamp, messages) .await
.await?) .handle_new_messages(contact_id, public_key_verified_timestamp, messages)
.await?)
}).await
} }
pub async fn update_verification_state_for_user( pub async fn update_verification_state_for_user(
callback_id: u32,
contact_id: i64, contact_id: i64,
public_key_verified_timestamp: Option<i64>, public_key_verified_timestamp: Option<i64>,
) -> Result<()> { ) -> Result<()> {
Ok(get_twonly_flutter()? CURRENT_CALLBACK_ID.scope(callback_id, async move {
.user_discovery Ok(get_twonly_flutter()?
.get() .user_discovery
.await .get()
.update_verification_state_for_user(contact_id, public_key_verified_timestamp) .await
.await?) .update_verification_state_for_user(contact_id, public_key_verified_timestamp)
.await?)
}).await
} }
} }

View file

@ -55,9 +55,9 @@ fn wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_get_curr
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec,_,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_get_current_version", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec,_,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_get_current_version", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || {
let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) };
let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end(); move |context| async move { let api_callback_id = <u32>::sse_decode(&mut deserializer);deserializer.end(); move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move {
let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::get_current_version().await?; Ok(output_ok) let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::get_current_version(api_callback_id).await?; Ok(output_ok)
})().await) })().await)
} }) } })
} }
@ -70,10 +70,11 @@ fn wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_get_new_
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec,_,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_get_new_messages", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec,_,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_get_new_messages", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || {
let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) };
let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_contact_id = <i64>::sse_decode(&mut deserializer); let api_callback_id = <u32>::sse_decode(&mut deserializer);
let api_contact_id = <i64>::sse_decode(&mut deserializer);
let api_received_version = <Vec<u8>>::sse_decode(&mut deserializer);deserializer.end(); move |context| async move { let api_received_version = <Vec<u8>>::sse_decode(&mut deserializer);deserializer.end(); move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move {
let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::get_new_messages(api_contact_id, &api_received_version).await?; Ok(output_ok) let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::get_new_messages(api_callback_id, api_contact_id, &api_received_version).await?; Ok(output_ok)
})().await) })().await)
} }) } })
} }
@ -86,11 +87,12 @@ fn wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_handle_n
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec,_,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_handle_new_messages", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec,_,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_handle_new_messages", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || {
let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) };
let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_contact_id = <i64>::sse_decode(&mut deserializer); let api_callback_id = <u32>::sse_decode(&mut deserializer);
let api_contact_id = <i64>::sse_decode(&mut deserializer);
let api_public_key_verified_timestamp = <Option<i64>>::sse_decode(&mut deserializer); let api_public_key_verified_timestamp = <Option<i64>>::sse_decode(&mut deserializer);
let api_messages = <Vec<Vec<u8>>>::sse_decode(&mut deserializer);deserializer.end(); move |context| async move { let api_messages = <Vec<Vec<u8>>>::sse_decode(&mut deserializer);deserializer.end(); move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move {
let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::handle_new_messages(api_contact_id, api_public_key_verified_timestamp, api_messages).await?; Ok(output_ok) let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::handle_new_messages(api_callback_id, api_contact_id, api_public_key_verified_timestamp, api_messages).await?; Ok(output_ok)
})().await) })().await)
} }) } })
} }
@ -103,12 +105,13 @@ fn wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_initiali
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec,_,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_initialize_or_update", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec,_,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_initialize_or_update", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || {
let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) };
let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_threshold = <u8>::sse_decode(&mut deserializer); let api_callback_id = <u32>::sse_decode(&mut deserializer);
let api_threshold = <u8>::sse_decode(&mut deserializer);
let api_user_id = <i64>::sse_decode(&mut deserializer); let api_user_id = <i64>::sse_decode(&mut deserializer);
let api_public_key = <Vec<u8>>::sse_decode(&mut deserializer); let api_public_key = <Vec<u8>>::sse_decode(&mut deserializer);
let api_share_promotion = <bool>::sse_decode(&mut deserializer);deserializer.end(); move |context| async move { let api_share_promotion = <bool>::sse_decode(&mut deserializer);deserializer.end(); move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move {
let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::initialize_or_update(api_threshold, api_user_id, api_public_key, api_share_promotion).await?; Ok(output_ok) let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::initialize_or_update(api_callback_id, api_threshold, api_user_id, api_public_key, api_share_promotion).await?; Ok(output_ok)
})().await) })().await)
} }) } })
} }
@ -121,10 +124,11 @@ fn wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_should_r
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec,_,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_should_request_new_messages", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec,_,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_should_request_new_messages", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || {
let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) };
let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_contact_id = <i64>::sse_decode(&mut deserializer); let api_callback_id = <u32>::sse_decode(&mut deserializer);
let api_contact_id = <i64>::sse_decode(&mut deserializer);
let api_version = <Vec<u8>>::sse_decode(&mut deserializer);deserializer.end(); move |context| async move { let api_version = <Vec<u8>>::sse_decode(&mut deserializer);deserializer.end(); move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move {
let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::should_request_new_messages(api_contact_id, &api_version).await?; Ok(output_ok) let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::should_request_new_messages(api_callback_id, api_contact_id, &api_version).await?; Ok(output_ok)
})().await) })().await)
} }) } })
} }
@ -137,10 +141,11 @@ fn wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_update_v
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec,_,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_update_verification_state_for_user", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec,_,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "flutter_user_discovery_update_verification_state_for_user", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || {
let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) };
let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_contact_id = <i64>::sse_decode(&mut deserializer); let api_callback_id = <u32>::sse_decode(&mut deserializer);
let api_contact_id = <i64>::sse_decode(&mut deserializer);
let api_public_key_verified_timestamp = <Option<i64>>::sse_decode(&mut deserializer);deserializer.end(); move |context| async move { let api_public_key_verified_timestamp = <Option<i64>>::sse_decode(&mut deserializer);deserializer.end(); move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move {
let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::update_verification_state_for_user(api_contact_id, api_public_key_verified_timestamp).await?; Ok(output_ok) let output_ok = crate::bridge::wrapper::user_discovery::FlutterUserDiscovery::update_verification_state_for_user(api_callback_id, api_contact_id, api_public_key_verified_timestamp).await?; Ok(output_ok)
})().await) })().await)
} }) } })
} }
@ -153,7 +158,8 @@ fn wire__crate__bridge__callbacks__init_flutter_callbacks_impl(
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "init_flutter_callbacks", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "init_flutter_callbacks", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || {
let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) };
let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_logging_get_stream_sink = decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer)); let api_callback_id = <u32>::sse_decode(&mut deserializer);
let api_logging_get_stream_sink = decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer));
let api_user_discovery_sign_data = decode_DartFn_Inputs_list_prim_u_8_strict_Output_opt_list_prim_u_8_strict_AnyhowException(<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer)); let api_user_discovery_sign_data = decode_DartFn_Inputs_list_prim_u_8_strict_Output_opt_list_prim_u_8_strict_AnyhowException(<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer));
let api_user_discovery_verify_signature = decode_DartFn_Inputs_list_prim_u_8_strict_list_prim_u_8_strict_list_prim_u_8_strict_Output_bool_AnyhowException(<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer)); let api_user_discovery_verify_signature = decode_DartFn_Inputs_list_prim_u_8_strict_list_prim_u_8_strict_list_prim_u_8_strict_Output_bool_AnyhowException(<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer));
let api_user_discovery_verify_stored_pubkey = decode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_bool_AnyhowException(<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer)); let api_user_discovery_verify_stored_pubkey = decode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_bool_AnyhowException(<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer));
@ -169,7 +175,7 @@ let api_user_discovery_set_contact_version = decode_DartFn_Inputs_i_64_list_prim
let api_user_discovery_push_new_user_relation = decode_DartFn_Inputs_i_64_announced_user_opt_box_autoadd_i_64_Output_bool_AnyhowException(<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer)); let api_user_discovery_push_new_user_relation = decode_DartFn_Inputs_i_64_announced_user_opt_box_autoadd_i_64_Output_bool_AnyhowException(<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer));
let api_user_discovery_get_contact_promotion = decode_DartFn_Inputs_i_64_Output_opt_list_prim_u_8_strict_AnyhowException(<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer));deserializer.end(); move |context| { let api_user_discovery_get_contact_promotion = decode_DartFn_Inputs_i_64_Output_opt_list_prim_u_8_strict_AnyhowException(<flutter_rust_bridge::DartOpaque>::sse_decode(&mut deserializer));deserializer.end(); move |context| {
transform_result_sse::<_, ()>((move || { transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_,()>::Ok({ crate::bridge::callbacks::init_flutter_callbacks(api_logging_get_stream_sink, api_user_discovery_sign_data, api_user_discovery_verify_signature, api_user_discovery_verify_stored_pubkey, api_user_discovery_set_shares, api_user_discovery_get_share_for_contact, api_user_discovery_push_own_promotion_and_clear_old_version, api_user_discovery_get_own_promotions_after_version, api_user_discovery_store_other_promotion, api_user_discovery_get_other_promotions_by_public_id, api_user_discovery_get_announced_user_by_public_id, api_user_discovery_get_contact_version, api_user_discovery_set_contact_version, api_user_discovery_push_new_user_relation, api_user_discovery_get_contact_promotion); })?; Ok(output_ok) let output_ok = Result::<_,()>::Ok({ crate::bridge::callbacks::init_flutter_callbacks(api_callback_id, api_logging_get_stream_sink, api_user_discovery_sign_data, api_user_discovery_verify_signature, api_user_discovery_verify_stored_pubkey, api_user_discovery_set_shares, api_user_discovery_get_share_for_contact, api_user_discovery_push_own_promotion_and_clear_old_version, api_user_discovery_get_own_promotions_after_version, api_user_discovery_store_other_promotion, api_user_discovery_get_other_promotions_by_public_id, api_user_discovery_get_announced_user_by_public_id, api_user_discovery_get_contact_version, api_user_discovery_set_contact_version, api_user_discovery_push_new_user_relation, api_user_discovery_get_contact_promotion); })?; Ok(output_ok)
})()) })())
} }) } })
} }

BIN
test.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 B

View file

@ -0,0 +1,118 @@
import 'package:clock/clock.dart';
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/json/userdata.model.dart';
import 'package:twonly/src/services/user.service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
await locator.reset();
locator
..registerSingleton<TwonlyDB>(
TwonlyDB.forTesting(
DatabaseConnection(
NativeDatabase.memory(),
closeStreamsSynchronously: true,
),
),
)
..registerSingleton<UserService>(UserService());
userService.currentUser = UserData(
userId: 1,
username: 'test_user',
displayName: 'Test User',
subscriptionPlan: 'Free',
currentSetupPage: null,
appVersion: 100,
);
userService.isUserCreated = true;
});
tearDown(() async {
await twonlyDB.close();
});
test('purgeMessageTable preserves unopened messages and deletes expired ones', () async {
final now = clock.now();
const retentionMs = 7200000; // 2 hours
final deletionLimit = now.subtract(const Duration(milliseconds: retentionMs));
// 1. Insert a group with 2 hour retention policy
await twonlyDB.groupsDao.createNewGroup(
GroupsCompanion.insert(
groupId: 'test_group',
groupName: 'Test Group',
deleteMessagesAfterMilliseconds: const Value(retentionMs),
),
);
// 2. Insert test messages:
// Msg A: Unopened (openedByAll is null)
await twonlyDB.messagesDao.insertMessage(
MessagesCompanion.insert(
messageId: 'msg_a_unopened',
groupId: 'test_group',
type: 'text',
createdAt: Value(deletionLimit.subtract(const Duration(minutes: 5))), // older than deletion threshold
),
);
// Msg B: Opened long ago (openedByAll is older than deletion threshold)
await twonlyDB.messagesDao.insertMessage(
MessagesCompanion.insert(
messageId: 'msg_b_opened_expired',
groupId: 'test_group',
type: 'text',
openedByAll: Value(deletionLimit.subtract(const Duration(minutes: 5))),
createdAt: Value(deletionLimit.subtract(const Duration(minutes: 30))),
),
);
// Msg C: Opened recently (openedByAll is newer than deletion threshold)
await twonlyDB.messagesDao.insertMessage(
MessagesCompanion.insert(
messageId: 'msg_c_opened_recent',
groupId: 'test_group',
type: 'text',
openedByAll: Value(deletionLimit.add(const Duration(minutes: 5))),
createdAt: Value(deletionLimit.subtract(const Duration(minutes: 10))),
),
);
// Msg D: Deleted from sender, older than threshold
await twonlyDB.messagesDao.insertMessage(
MessagesCompanion.insert(
messageId: 'msg_d_sender_deleted_expired',
groupId: 'test_group',
type: 'text',
isDeletedFromSender: const Value(true),
createdAt: Value(deletionLimit.subtract(const Duration(minutes: 5))),
),
);
// Run purge
await twonlyDB.messagesDao.purgeMessageTable();
// Verify database state
final allMessages = await twonlyDB.select(twonlyDB.messages).get();
final remainingIds = allMessages.map((m) => m.messageId).toList();
// msg_a_unopened should be preserved because it was never opened (openedByAll was null)
expect(remainingIds.contains('msg_a_unopened'), isTrue);
// msg_b_opened_expired should be deleted because openedByAll < deletionLimit
expect(remainingIds.contains('msg_b_opened_expired'), isFalse);
// msg_c_opened_recent should be preserved because openedByAll >= deletionLimit
expect(remainingIds.contains('msg_c_opened_recent'), isTrue);
// msg_d_sender_deleted_expired should be deleted because isDeletedFromSender is true and createdAt < deletionLimit
expect(remainingIds.contains('msg_d_sender_deleted_expired'), isFalse);
});
}