diff --git a/turms-chat-demo-flutter/.env b/turms-chat-demo-flutter/.env new file mode 100644 index 0000000000..586ebf8e37 --- /dev/null +++ b/turms-chat-demo-flutter/.env @@ -0,0 +1,19 @@ +# App - Appearance +WINDOW_TITLE="Turms Chat Demo" + +# App - Storage +DATABASE_LOG_STATEMENTS=false +SECURE_STORAGE=true + +# App - Debugger +SHOW_FOCUS_TRACKER=true + +# Business +MESSAGE_IMAGE_MAX_DOWNLOADABLE_SIZE_BYTES=10485760 +MESSAGE_IMAGE_MAX_CACHED_SIZE_WIDTH=2048.0 +MESSAGE_IMAGE_MAX_CACHED_SIZE_HEIGHT=2048.0 +MESSAGE_IMAGE_THUMBNAIL_SIZE_WIDTH=200.0 +MESSAGE_IMAGE_THUMBNAIL_SIZE_HEIGHT=200.0 + +# Giphy +GIPHY_API_KEY= \ No newline at end of file diff --git a/turms-chat-demo-flutter/.gitignore b/turms-chat-demo-flutter/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/turms-chat-demo-flutter/.metadata b/turms-chat-demo-flutter/.metadata new file mode 100644 index 0000000000..ab3e1c09c8 --- /dev/null +++ b/turms-chat-demo-flutter/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ead455963c12b453cdb2358cad34969c76daf180" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ead455963c12b453cdb2358cad34969c76daf180 + base_revision: ead455963c12b453cdb2358cad34969c76daf180 + - platform: android + create_revision: ead455963c12b453cdb2358cad34969c76daf180 + base_revision: ead455963c12b453cdb2358cad34969c76daf180 + - platform: ios + create_revision: ead455963c12b453cdb2358cad34969c76daf180 + base_revision: ead455963c12b453cdb2358cad34969c76daf180 + - platform: linux + create_revision: ead455963c12b453cdb2358cad34969c76daf180 + base_revision: ead455963c12b453cdb2358cad34969c76daf180 + - platform: macos + create_revision: ead455963c12b453cdb2358cad34969c76daf180 + base_revision: ead455963c12b453cdb2358cad34969c76daf180 + - platform: web + create_revision: ead455963c12b453cdb2358cad34969c76daf180 + base_revision: ead455963c12b453cdb2358cad34969c76daf180 + - platform: windows + create_revision: ead455963c12b453cdb2358cad34969c76daf180 + base_revision: ead455963c12b453cdb2358cad34969c76daf180 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/turms-chat-demo-flutter/analysis_options.yaml b/turms-chat-demo-flutter/analysis_options.yaml new file mode 100644 index 0000000000..f88bf6a67a --- /dev/null +++ b/turms-chat-demo-flutter/analysis_options.yaml @@ -0,0 +1,133 @@ +# https://dart.dev/guides/language/analysis-options +# https://dart.dev/tools/linter-rules + +include: + - package:flutter_lints/flutter.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + plugins: + - custom_lint +# exclude: +# - lib/components/flutter_quill/** +# - lib/**.g.dart + +linter: + rules: + - always_declare_return_types + - always_put_control_body_on_new_line + - annotate_overrides + - avoid_bool_literals_in_conditional_expressions + - avoid_catching_errors + # We need to use the utility class as the namespace +# - avoid_classes_with_only_static_members + - avoid_escaping_inner_quotes + - avoid_dynamic_calls + - avoid_empty_else + - avoid_function_literals_in_foreach_calls + - avoid_js_rounded_ints + - avoid_init_to_null + - avoid_null_checks_in_equality_operators + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + - avoid_returning_null_for_void + - avoid_returning_this + - avoid_shadowing_type_parameters + - avoid_single_cascade_in_expression_statements + - avoid_type_to_string + - avoid_types_as_parameter_names + - avoid_unused_constructor_parameters + - avoid_void_async + - await_only_futures + - camel_case_types + - cascade_invocations + - collection_methods_unrelated_type + - comment_references + - constant_identifier_names + - control_flow_in_finally + - curly_braces_in_flow_control_structures + - directives_ordering + - empty_catches + - empty_constructor_bodies + - empty_statements + - file_names + - hash_and_equals + - implementation_imports + - join_return_with_assignment + - library_names + - library_prefixes + - lines_longer_than_80_chars + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_duplicate_case_values + - no_runtimeType_toString + - non_constant_identifier_names + - null_closures + - omit_local_variable_types + - only_throw_errors + - overridden_fields + - package_names + - package_prefixed_library_names + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_declarations + - prefer_contains + - prefer_expression_function_bodies + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + - prefer_function_declarations_over_variables + - prefer_generic_function_type_aliases + - prefer_initializing_formals + - prefer_inlined_adds + - prefer_interpolation_to_compose_strings + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_null_aware_method_calls + - prefer_null_aware_operators + - prefer_relative_imports + - prefer_single_quotes + - prefer_typing_uninitialized_variables + - prefer_void_to_null + - provide_deprecation_message + - recursive_getters + - slash_for_doc_comments + - sort_constructors_first + - sort_pub_dependencies + - test_types_in_equals + - throw_in_finally + - type_annotate_public_apis + - type_init_formals + - unawaited_futures + - unnecessary_await_in_return + - unnecessary_brace_in_string_interps + - unnecessary_const + - unnecessary_getters_setters + - unnecessary_lambdas + - unnecessary_late + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_in_if_null_operators + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_statements + - unnecessary_string_interpolations + - unnecessary_this + - unrelated_type_equality_checks + - use_is_even_rather_than_modulo + - use_named_constants + - use_rethrow_when_possible + - use_setters_to_change_properties + - use_string_buffers + - valid_regexps + - void_checks \ No newline at end of file diff --git a/turms-chat-demo-flutter/devtools_options.yaml b/turms-chat-demo-flutter/devtools_options.yaml new file mode 100644 index 0000000000..e0b5c91431 --- /dev/null +++ b/turms-chat-demo-flutter/devtools_options.yaml @@ -0,0 +1,2 @@ +extensions: + - drift: true \ No newline at end of file diff --git a/turms-chat-demo-flutter/flutter_launcher_icons.yaml b/turms-chat-demo-flutter/flutter_launcher_icons.yaml new file mode 100644 index 0000000000..7042b9499f --- /dev/null +++ b/turms-chat-demo-flutter/flutter_launcher_icons.yaml @@ -0,0 +1,12 @@ +flutter_launcher_icons: + # TODO: Add support for mobile platforms + web: + generate: true + image_path: "assets/images/icon_1024.png" + windows: + generate: true + image_path: "assets/images/icon_1024.png" + icon_size: 256 # min:48, max:256, default: 48 + macos: + generate: true + image_path: "assets/images/icon_1024.png" \ No newline at end of file diff --git a/turms-chat-demo-flutter/flutter_rust_bridge.yaml b/turms-chat-demo-flutter/flutter_rust_bridge.yaml new file mode 100644 index 0000000000..4d86d8071c --- /dev/null +++ b/turms-chat-demo-flutter/flutter_rust_bridge.yaml @@ -0,0 +1,4 @@ +# https://cjycode.com/flutter_rust_bridge/v1/command_line.html +rust_input: crate::api +rust_root: rust/ +dart_output: lib/infra/rust \ No newline at end of file diff --git a/turms-chat-demo-flutter/ios/.gitignore b/turms-chat-demo-flutter/ios/.gitignore new file mode 100644 index 0000000000..7a7f9873ad --- /dev/null +++ b/turms-chat-demo-flutter/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/turms-chat-demo-flutter/ios/Flutter/AppFrameworkInfo.plist b/turms-chat-demo-flutter/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000..9625e105df --- /dev/null +++ b/turms-chat-demo-flutter/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 11.0 + + diff --git a/turms-chat-demo-flutter/ios/Flutter/Debug.xcconfig b/turms-chat-demo-flutter/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000000..592ceee85b --- /dev/null +++ b/turms-chat-demo-flutter/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/turms-chat-demo-flutter/ios/Flutter/Release.xcconfig b/turms-chat-demo-flutter/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000000..592ceee85b --- /dev/null +++ b/turms-chat-demo-flutter/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/turms-chat-demo-flutter/ios/Runner.xcodeproj/project.pbxproj b/turms-chat-demo-flutter/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..598d02e5f8 --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,614 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807E294A63A400263BE5 /* Frameworks */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = im.turms.turmsChatDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AE0B7B92F70575B8D7E0D07E /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = im.turms.turmsChatDemo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 89B67EB44CE7B6631473024E /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = im.turms.turmsChatDemo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 640959BDD8F10B91D80A66BE /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = im.turms.turmsChatDemo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = im.turms.turmsChatDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = im.turms.turmsChatDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/turms-chat-demo-flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/turms-chat-demo-flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/turms-chat-demo-flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/turms-chat-demo-flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/turms-chat-demo-flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/turms-chat-demo-flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/turms-chat-demo-flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/turms-chat-demo-flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..87131a09be --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/turms-chat-demo-flutter/ios/Runner.xcworkspace/contents.xcworkspacedata b/turms-chat-demo-flutter/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..1d526a16ed --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/turms-chat-demo-flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/turms-chat-demo-flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/turms-chat-demo-flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/turms-chat-demo-flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/turms-chat-demo-flutter/ios/Runner/AppDelegate.swift b/turms-chat-demo-flutter/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000000..70693e4a8c --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/turms-chat-demo-flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/turms-chat-demo-flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d36b1fab2d --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/turms-chat-demo-flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/turms-chat-demo-flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000000..0bedcf2fd4 --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/turms-chat-demo-flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/turms-chat-demo-flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000000..89c2725b70 --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/turms-chat-demo-flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard b/turms-chat-demo-flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..f2e259c7c9 --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/turms-chat-demo-flutter/ios/Runner/Base.lproj/Main.storyboard b/turms-chat-demo-flutter/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..f3c28516fb --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/turms-chat-demo-flutter/ios/Runner/Info.plist b/turms-chat-demo-flutter/ios/Runner/Info.plist new file mode 100644 index 0000000000..e2100606e0 --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Turms Chat Demo + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + turms_chat_demo + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/turms-chat-demo-flutter/ios/Runner/Runner-Bridging-Header.h b/turms-chat-demo-flutter/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000000..308a2a560b --- /dev/null +++ b/turms-chat-demo-flutter/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/turms-chat-demo-flutter/ios/RunnerTests/RunnerTests.swift b/turms-chat-demo-flutter/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..86a7c3b1b6 --- /dev/null +++ b/turms-chat-demo-flutter/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/turms-chat-demo-flutter/l10n.yaml b/turms-chat-demo-flutter/l10n.yaml new file mode 100644 index 0000000000..de5252a5a1 --- /dev/null +++ b/turms-chat-demo-flutter/l10n.yaml @@ -0,0 +1,7 @@ +# https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization#configuring-the-l10nyaml-file +arb-dir: lib/ui/l10n/arb +output-dir: lib/ui/l10n +output-localization-file: app_localizations.dart +synthetic-package: false +template-arb-file: app_en.arb +nullable-getter: false \ No newline at end of file diff --git a/turms-chat-demo-flutter/lib/domain/app/models/app_setting.dart b/turms-chat-demo-flutter/lib/domain/app/models/app_setting.dart new file mode 100644 index 0000000000..17b79fad05 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/app/models/app_setting.dart @@ -0,0 +1,9 @@ +enum AppSetting { + rememberMe(0); + + const AppSetting(this.id); + + final int id; + + static AppSetting fromId(int id) => values.firstWhere((e) => e.id == id); +} diff --git a/turms-chat-demo-flutter/lib/domain/app/models/app_settings.dart b/turms-chat-demo-flutter/lib/domain/app/models/app_settings.dart new file mode 100644 index 0000000000..c975bb38f8 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/app/models/app_settings.dart @@ -0,0 +1,31 @@ +import '../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../infra/sqlite/app_database.dart'; +import 'app_setting.dart'; + +class AppSettings { + AppSettings(this._settingToValue); + + factory AppSettings.fromTableData(List records) { + final entries = records.map((r) => (AppSetting.fromId(r.id), r)); + final idToSetting = { + for (final entry in entries) + entry.$1: _parseValue(entry.$1, entry.$2.value.rawSqlValue) + }; + return AppSettings(idToSetting); + } + + final Map _settingToValue; + + static Object _parseValue(AppSetting setting, Object value) => + switch (setting) { + AppSetting.rememberMe => (value as int).toBool(), + }; + + bool? getRememberMe() { + final value = _settingToValue[AppSetting.rememberMe]; + if (value == null) { + return null; + } + return value as bool; + } +} diff --git a/turms-chat-demo-flutter/lib/domain/app/repositories/app_setting_repository.dart b/turms-chat-demo-flutter/lib/domain/app/repositories/app_setting_repository.dart new file mode 100644 index 0000000000..40cfc8415d --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/app/repositories/app_setting_repository.dart @@ -0,0 +1,26 @@ +import 'package:drift/drift.dart'; + +import '../../../infra/sqlite/app_database.dart'; +import '../models/app_setting.dart'; + +class AppSettingRepository { + Future upsertRememberMe(bool rememberMe) async { + final now = DateTime.now(); + final rememberMeValue = DriftAny(rememberMe); + await appDatabase.into(appDatabase.appSettingTable).insert( + AppSettingTableCompanion.insert( + id: AppSetting.rememberMe.id, + value: rememberMeValue, + createdDate: now, + lastModifiedDate: now, + ), + onConflict: DoUpdate((old) => AppSettingTableCompanion.custom( + value: Constant(rememberMeValue), + lastModifiedDate: Constant(now)))); + } + + Future> selectAll() => + appDatabase.select(appDatabase.appSettingTable).get(); +} + +final appSettingRepository = AppSettingRepository(); diff --git a/turms-chat-demo-flutter/lib/domain/app/tables/app_setting_table.dart b/turms-chat-demo-flutter/lib/domain/app/tables/app_setting_table.dart new file mode 100644 index 0000000000..c43932ad6e --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/app/tables/app_setting_table.dart @@ -0,0 +1,23 @@ +import 'package:drift/drift.dart'; + +class AppSettingTable extends Table { + late final id = integer()(); + + late final value = sqliteAny()(); + + late final createdDate = dateTime()(); + + late final lastModifiedDate = dateTime()(); + + @override + String get tableName => 'app_setting'; + + @override + Set get primaryKey => {id}; + + @override + bool get withoutRowId => true; + + @override + bool get isStrict => true; +} diff --git a/turms-chat-demo-flutter/lib/domain/app/tables/log_entry_table.dart b/turms-chat-demo-flutter/lib/domain/app/tables/log_entry_table.dart new file mode 100644 index 0000000000..9e4a7b4ac7 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/app/tables/log_entry_table.dart @@ -0,0 +1,19 @@ +import 'package:drift/drift.dart'; + +import 'log_level_converter.dart'; + +class LogEntryTable extends Table { + late final id = integer().autoIncrement()(); + + late final level = integer().map(LogLevelConverter())(); + + late final createdDate = dateTime()(); + + late final message = text()(); + + @override + String get tableName => 'log_entry'; + + @override + bool get isStrict => true; +} diff --git a/turms-chat-demo-flutter/lib/domain/app/tables/log_level_converter.dart b/turms-chat-demo-flutter/lib/domain/app/tables/log_level_converter.dart new file mode 100644 index 0000000000..57eec8e1f0 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/app/tables/log_level_converter.dart @@ -0,0 +1,11 @@ +import 'package:drift/drift.dart'; + +import '../../../infra/logging/log_level.dart'; + +class LogLevelConverter extends TypeConverter { + @override + LogLevel fromSql(int fromDb) => LogLevel.fromInt(fromDb)!; + + @override + int toSql(LogLevel value) => value.value; +} diff --git a/turms-chat-demo-flutter/lib/domain/app/view_models/app_settings_view_model.dart b/turms-chat-demo-flutter/lib/domain/app/view_models/app_settings_view_model.dart new file mode 100644 index 0000000000..9298e5f85b --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/app/view_models/app_settings_view_model.dart @@ -0,0 +1,5 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/app_settings.dart'; + +final appSettingsViewModel = StateProvider((ref) => null); diff --git a/turms-chat-demo-flutter/lib/domain/common/fixtures/fixtures.dart b/turms-chat-demo-flutter/lib/domain/common/fixtures/fixtures.dart new file mode 100644 index 0000000000..fc8eb66b96 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/common/fixtures/fixtures.dart @@ -0,0 +1,5 @@ +class Fixtures { + Fixtures._(); + + static final instance = Fixtures._(); +} diff --git a/turms-chat-demo-flutter/lib/domain/common/models/new_relationship_request.dart b/turms-chat-demo-flutter/lib/domain/common/models/new_relationship_request.dart new file mode 100644 index 0000000000..0ef1e1d256 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/common/models/new_relationship_request.dart @@ -0,0 +1,18 @@ +import 'package:fixnum/fixnum.dart'; +import '../../common/models/request_status.dart'; +import '../../user/models/index.dart'; + +abstract class NewRelationshipRequest { + NewRelationshipRequest( + {required this.id, + required this.status, + required this.sender, + required this.creationDate, + required this.message}); + + final Int64 id; + final RequestStatus status; + final User sender; + final DateTime creationDate; + final String message; +} diff --git a/turms-chat-demo-flutter/lib/domain/common/models/request_status.dart b/turms-chat-demo-flutter/lib/domain/common/models/request_status.dart new file mode 100644 index 0000000000..ae442d957b --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/common/models/request_status.dart @@ -0,0 +1,5 @@ +enum RequestStatus { + pending, + accepted, + // declined, +} diff --git a/turms-chat-demo-flutter/lib/domain/conversation/fixtures/conversations.dart b/turms-chat-demo-flutter/lib/domain/conversation/fixtures/conversations.dart new file mode 100644 index 0000000000..e2a2a40340 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/conversation/fixtures/conversations.dart @@ -0,0 +1,98 @@ +import 'dart:math'; + +import 'package:fixnum/fixnum.dart'; + +import '../../../infra/random/random_utils.dart'; +import '../../../ui/desktop/pages/home_page/chat_page/chat_session_pane/message.dart'; +import '../../common/fixtures/fixtures.dart'; +import '../../message/models/message_delivery_status.dart'; +import '../../user/fixtures/contacts.dart'; +import '../../user/models/index.dart'; +import '../models/conversation.dart'; + +// We prefer the same output for each run, +// so we use the same seed. +final _random = Random(123456789); + +const _deliveryStatuses = MessageDeliveryStatus.values; + +extension FixturesExtensions on Fixtures { + List getConversations(User loggedInUser) { + final loggedInUserId = loggedInUser.userId; + var messageId = Int64.ZERO; + final now = DateTime.now(); + return getContacts(loggedInUser).map((contact) { + switch (contact) { + case UserContact(): + final timestamps = []; + final rawMessages = contactIdToMessages[contact.userId]!; + final count = rawMessages.length; + var date = now; + for (var i = 0; i < count; i++) { + date = date.subtract( + Duration(seconds: _random.nextInt(60 * 60 * 24 * 30))); + timestamps.add(date); + } + return UserConversation( + contact: contact, + unreadMessageCount: + rawMessages.isEmpty ? 0 : _random.nextInt(rawMessages.length), + messages: rawMessages.indexed.map((item) { + final (messageIndex, message) = item; + final sentByMe = _random.nextBool(); + return ChatMessage.parse( + text: message, + messageId: --messageId, + senderId: sentByMe ? loggedInUserId : contact.userId, + sentByMe: sentByMe, + recipientId: sentByMe ? contact.userId : loggedInUserId, + isGroupMessage: false, + timestamp: timestamps[count - messageIndex - 1], + status: sentByMe + ? _deliveryStatuses[ + _random.nextInt(_deliveryStatuses.length)] + : MessageDeliveryStatus.delivered); + }).toList()); + case GroupContact(): + final memberIds = + contact.members.map((member) => member.userId).toList(); + final memberCount = memberIds.length; + final maxMessageCount = RandomUtils.nextInt() % 20; + final messages = []; + var date = now; + final timestamps = []; + for (var i = 0; i < maxMessageCount; i++) { + date = date.subtract( + Duration(seconds: _random.nextInt(60 * 60 * 24 * 30))); + timestamps.add(date); + } + for (var i = 0; i < maxMessageCount; i++) { + final memberId = memberIds[RandomUtils.nextInt() % memberCount]; + final rawMessages = contactIdToMessages[memberId] ?? []; + final messageCount = rawMessages.length; + if (messageCount == 0) { + continue; + } + final rawMessage = + rawMessages[RandomUtils.nextInt() % messageCount]; + messages.add(ChatMessage.parse( + text: rawMessage, + messageId: --messageId, + groupId: contact.groupId, + senderId: memberId, + sentByMe: loggedInUserId == memberId, + isGroupMessage: true, + timestamp: timestamps[maxMessageCount - i - 1], + status: MessageDeliveryStatus.delivered)); + } + return GroupConversation( + contact: contact, + unreadMessageCount: + messages.isEmpty ? 0 : _random.nextInt(messages.length), + messages: messages); + case SystemContact(): + throw UnimplementedError(); + } + }).toList(); + } +} diff --git a/turms-chat-demo-flutter/lib/domain/conversation/models/conversation.dart b/turms-chat-demo-flutter/lib/domain/conversation/models/conversation.dart new file mode 100644 index 0000000000..b118e97464 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/conversation/models/conversation.dart @@ -0,0 +1,63 @@ +import 'package:fixnum/fixnum.dart'; + +import '../../../infra/collection/list_holder.dart'; +import '../../../ui/desktop/pages/home_page/chat_page/chat_session_pane/message.dart'; +import '../../user/models/contact.dart'; + +part './group_conversation.dart'; + +part './private_conversation.dart'; + +part './user_conversation.dart'; + +part './system_conversation.dart'; + +sealed class Conversation { + factory Conversation.from( + {required Contact contact, required List messages}) => + switch (contact) { + UserContact() => UserConversation(contact: contact, messages: messages), + GroupContact() => + GroupConversation(contact: contact, messages: messages), + SystemContact() => + SystemConversation(contact: contact, messages: messages), + }; + + Conversation( + {required this.id, + required this.messages, + this.unreadMessageCount = 0, + this.draft}); + + final IntListHolder id; + + /// Note that the messages should be sorted by timestamp in ascending order. + final List messages; + int unreadMessageCount; + String? draft; + + abstract final Contact contact; + + bool hasSameContact(Contact contact); + + static IntListHolder generateId( + {Int64? userId, Int64? groupId, SystemContactType? systemContactType}) { + assert((userId != null) ^ (groupId != null) ^ (systemContactType != null), + 'Only one parameter should be not null'); + final Int64 contactId; + final int idFlag; + if (userId != null) { + contactId = userId; + idFlag = 0; + } else if (groupId != null) { + contactId = groupId; + idFlag = 1; + } else { + contactId = Int64(systemContactType!.id); + idFlag = 2; + } + return ListHolder(List.filled(9, 0) + ..setAll(0, contactId.toInt64().toBytes()) + ..[8] = idFlag); + } +} diff --git a/turms-chat-demo-flutter/lib/domain/conversation/models/conversation_setting.dart b/turms-chat-demo-flutter/lib/domain/conversation/models/conversation_setting.dart new file mode 100644 index 0000000000..6e0886b658 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/conversation/models/conversation_setting.dart @@ -0,0 +1,31 @@ +import '../../../infra/built_in_types/built_in_type_helpers.dart'; + +/// [D] for the dart value type; +/// [S] for the SQL value type. +enum ConversationSetting { + pinned(0), + enableNewMessageNotification(1); + + const ConversationSetting(this.id); + + final int id; + + static ConversationSetting? fromId(int id) => + _idToSetting[id]; + + S? convertValueToSql(D value) => switch (this) { + ConversationSetting.pinned => (value as bool).toInt(), + ConversationSetting.enableNewMessageNotification => + (value as bool).toInt(), + } as S?; + + D? convertSqlToValue(S value) => switch (this) { + ConversationSetting.pinned => (value as int).toBool(), + ConversationSetting.enableNewMessageNotification => + (value as int).toBool(), + } as D?; +} + +final _idToSetting = { + for (final record in ConversationSetting.values) record.id: record, +}; diff --git a/turms-chat-demo-flutter/lib/domain/conversation/models/conversation_settings.dart b/turms-chat-demo-flutter/lib/domain/conversation/models/conversation_settings.dart new file mode 100644 index 0000000000..d4e06b64c7 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/conversation/models/conversation_settings.dart @@ -0,0 +1,71 @@ +import '../../../infra/collection/list_holder.dart'; +import '../../../infra/exception/stackful_exception.dart'; +import '../../../infra/sqlite/user_database.dart'; +import '../tables/conversation_setting_table.dart'; +import 'conversation_setting.dart'; + +class ConversationSettings { + const ConversationSettings(this._settingToValue); + + static (Map, StackfulException?) + fromTableData(List records) { + final idToSettings = {}; + StackfulException? exception; + for (final record in records) { + final setting = ConversationSetting.fromId(record.settingId); + final recordValue = record.value; + if (setting == null) { + continue; + } + final settings = idToSettings.putIfAbsent( + record.conversationId, + () => + // don't use const as the map should be mutable. + // ignore: prefer_const_constructors + ConversationSettings({})); + try { + _setSetting(settings, setting, recordValue.rawSqlValue); + } on Exception catch (e, s) { + exception ??= StackfulException( + cause: Exception('Failed to set the conversation settings'), + stackTrace: s, + suppressed: []); + exception.addSuppressed(StackfulException( + cause: Exception( + 'Failed to set the conversation setting "${setting.name}" with the value "$recordValue"'), + stackTrace: s, + suppressed: [e])); + } + } + return (idToSettings, exception); + } + + static void _setSetting(ConversationSettings settings, + ConversationSetting setting, Object sqlValue) { + final value = setting.convertSqlToValue(sqlValue); + switch (setting) { + case ConversationSetting.pinned: + settings.pinned = value as bool; + break; + case ConversationSetting.enableNewMessageNotification: + settings.enableNewMessageNotification = value as bool; + break; + } + } + + final Map, Object?> _settingToValue; + + bool? get pinned => _settingToValue[ConversationSetting.pinned] as bool?; + + set pinned(bool? value) { + _settingToValue[ConversationSetting.pinned] = value; + } + + bool? get enableNewMessageNotification => + _settingToValue[ConversationSetting.enableNewMessageNotification] + as bool?; + + set enableNewMessageNotification(bool? value) { + _settingToValue[ConversationSetting.enableNewMessageNotification] = value; + } +} diff --git a/turms-chat-demo-flutter/lib/domain/conversation/models/group_conversation.dart b/turms-chat-demo-flutter/lib/domain/conversation/models/group_conversation.dart new file mode 100644 index 0000000000..9c287c3494 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/conversation/models/group_conversation.dart @@ -0,0 +1,17 @@ +part of './conversation.dart'; + +class GroupConversation extends Conversation { + GroupConversation( + {required super.messages, + super.unreadMessageCount, + super.draft, + required this.contact}) + : super(id: Conversation.generateId(groupId: contact.groupId)); + + @override + final GroupContact contact; + + @override + bool hasSameContact(Contact contact) => + contact is GroupContact && contact.groupId == this.contact.groupId; +} diff --git a/turms-chat-demo-flutter/lib/domain/conversation/models/private_conversation.dart b/turms-chat-demo-flutter/lib/domain/conversation/models/private_conversation.dart new file mode 100644 index 0000000000..cd03e17e8c --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/conversation/models/private_conversation.dart @@ -0,0 +1,9 @@ +part of './conversation.dart'; + +sealed class PrivateConversation extends Conversation { + PrivateConversation( + {required super.id, + required super.messages, + super.unreadMessageCount, + super.draft}); +} diff --git a/turms-chat-demo-flutter/lib/domain/conversation/models/system_conversation.dart b/turms-chat-demo-flutter/lib/domain/conversation/models/system_conversation.dart new file mode 100644 index 0000000000..6689d949fc --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/conversation/models/system_conversation.dart @@ -0,0 +1,17 @@ +part of './conversation.dart'; + +class SystemConversation extends PrivateConversation { + SystemConversation( + {required super.messages, + super.unreadMessageCount, + super.draft, + required this.contact}) + : super(id: Conversation.generateId(systemContactType: contact.type)); + + @override + final SystemContact contact; + + @override + bool hasSameContact(Contact contact) => + contact is SystemContact && contact.id == this.contact.id; +} diff --git a/turms-chat-demo-flutter/lib/domain/conversation/models/user_conversation.dart b/turms-chat-demo-flutter/lib/domain/conversation/models/user_conversation.dart new file mode 100644 index 0000000000..91cdeb2b37 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/conversation/models/user_conversation.dart @@ -0,0 +1,17 @@ +part of './conversation.dart'; + +class UserConversation extends PrivateConversation { + UserConversation( + {required super.messages, + super.unreadMessageCount, + super.draft, + required this.contact}) + : super(id: Conversation.generateId(userId: contact.userId)); + + @override + final UserContact contact; + + @override + bool hasSameContact(Contact contact) => + contact is UserContact && contact.id == this.contact.id; +} diff --git a/turms-chat-demo-flutter/lib/domain/conversation/repositories/conversation_setting_repository.dart b/turms-chat-demo-flutter/lib/domain/conversation/repositories/conversation_setting_repository.dart new file mode 100644 index 0000000000..fd35148cc4 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/conversation/repositories/conversation_setting_repository.dart @@ -0,0 +1,56 @@ +import 'package:drift/drift.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../infra/sqlite/user_database.dart'; +import '../models/conversation_setting.dart'; + +class ConversationSettingRepository { + ConversationSettingRepository(this._userDatabase); + + final UserDatabase _userDatabase; + + Future upsert( + {required Int64 contactId, + required bool isGroupConversation, + required ConversationSetting setting, + required dynamic settingValue}) async { + final sqlValue = setting.convertValueToSql(settingValue) as Object; + final now = DateTime.now(); + await _userDatabase.into(_userDatabase.conversationSettingTable).insert( + ConversationSettingTableCompanion.insert( + contactId: contactId.toBigInt(), + isGroupConversation: isGroupConversation, + settingId: setting.id, + value: DriftAny(sqlValue), + createdDate: now, + lastModifiedDate: now, + ), + onConflict: DoUpdate((old) => ConversationSettingTableCompanion.custom( + value: Constant(DriftAny(sqlValue)), + lastModifiedDate: Constant(now)))); + } + + Future delete( + {required Int64 userId, + required Int64 contactId, + required bool isGroupConversation, + required ConversationSetting setting}) async { + final delete = _userDatabase.delete(_userDatabase.conversationSettingTable) + ..where((t) => Expression.and([ + t.contactId.equals(contactId.toBigInt()), + t.isGroupConversation.equals(isGroupConversation), + t.settingId.equals(setting.id) + ])); + return delete.go(); + } + + Future> selectAll() => + _userDatabase.select(_userDatabase.conversationSettingTable).get(); +} + +final conversationSettingRepositoryProvider = + StateProvider( + (ref) => null, +); diff --git a/turms-chat-demo-flutter/lib/domain/conversation/services/conversation_service.dart b/turms-chat-demo-flutter/lib/domain/conversation/services/conversation_service.dart new file mode 100644 index 0000000000..8f68a23fe6 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/conversation/services/conversation_service.dart @@ -0,0 +1,61 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../infra/collection/list_holder.dart'; +import '../../../ui/desktop/pages/app.dart'; +import '../../common/fixtures/fixtures.dart'; +import '../../conversation/fixtures/conversations.dart'; +import '../../user/models/contact.dart'; +import '../../user/models/user.dart'; +import '../models/conversation.dart'; +import '../models/conversation_setting.dart'; +import '../repositories/conversation_setting_repository.dart'; +import '../view_models/id_to_conversation_settings_view_model.dart'; + +class ConversationService { + const ConversationService(this._loggedInUser); + + final User _loggedInUser; + + Future> queryConversations() async { + await Future.delayed(const Duration(seconds: 3)); + return Fixtures.instance.getConversations(_loggedInUser); + } + + Future resetSharedUnreadMessageCount({ + Int64? groupId, + Int64? userId, + }) => + Future.delayed(const Duration(seconds: 1)); + + Future updateSettingPinned({ + required IntListHolder conversationId, + required bool newValue, + required Contact contact, + }) async { + readGlobalState(idToConversationSettingsViewModel.notifier) + .update(conversationId, pinned: newValue); + await readGlobalState(conversationSettingRepositoryProvider)!.upsert( + contactId: contact is SystemContact ? _loggedInUser.userId : contact.id, + isGroupConversation: contact is GroupContact, + setting: ConversationSetting.pinned, + settingValue: newValue); + } + + Future updateSettingEnableNewMessageNotification({ + required IntListHolder conversationId, + required bool newValue, + required Contact contact, + }) async { + readGlobalState(idToConversationSettingsViewModel.notifier) + .update(conversationId, enableNewMessageNotification: newValue); + await readGlobalState(conversationSettingRepositoryProvider)!.upsert( + contactId: contact is SystemContact ? _loggedInUser.userId : contact.id, + isGroupConversation: contact is GroupContact, + setting: ConversationSetting.enableNewMessageNotification, + settingValue: newValue); + } +} + +final conversationServiceProvider = + StateProvider((ref) => null); diff --git a/turms-chat-demo-flutter/lib/domain/conversation/tables/conversation_setting_table.dart b/turms-chat-demo-flutter/lib/domain/conversation/tables/conversation_setting_table.dart new file mode 100644 index 0000000000..ea6305dffe --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/conversation/tables/conversation_setting_table.dart @@ -0,0 +1,70 @@ +import 'package:drift/drift.dart'; +import 'package:fixnum/fixnum.dart'; + +import '../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../infra/collection/list_holder.dart'; +import '../../../infra/sqlite/user_database.dart'; +import '../models/conversation.dart'; + +/// We don't use the columns: a nullable [groupId] and a nullable [userId] +/// as the primary key as SQLite doesn't allow a primary key contains a null value, +/// and you can validate by running the following query: +/// ```sql +///create table my_test +/// ( +/// id1 INT default null, +/// id2 INT default null, +/// primary key (id1, id2) +/// ) without rowid , strict; +/// +/// -- This query will fail with the following error in SQLite 3.45.1: +/// -- [19] [SQLITE_CONSTRAINT_NOTNULL] A NOT NULL constraint failed (NOT NULL constraint failed: my_test.id2) +/// INSERT INTO my_test VALUES (1,null); +/// ``` +/// The table should only be used for development purposes without the server. +/// The client should fetch user settings dynamically from the server in +/// production. +class ConversationSettingTable extends Table { + /// This can be either a group ID or a user (recipient) ID. + late final contactId = int64()(); + + late final isGroupConversation = boolean()(); + + late final settingId = integer()(); + + late final value = sqliteAny()(); + + late final createdDate = dateTime()(); + + late final lastModifiedDate = dateTime()(); + + @override + String get tableName => 'conversation_setting'; + + @override + Set get primaryKey => {contactId, isGroupConversation, settingId}; + + @override + bool get withoutRowId => true; + + @override + bool get isStrict => true; +} + +extension ConversationSettingTableDataExtension + on ConversationSettingTableData { + IntListHolder get conversationId { + final id = contactId.toInt64(); + Int64? userId; + Int64? groupId; + if (isGroupConversation) { + groupId = id; + } else { + userId = id; + } + return Conversation.generateId( + userId: userId, + groupId: groupId, + ); + } +} diff --git a/turms-chat-demo-flutter/lib/domain/conversation/view_models/id_to_conversation_settings_view_model.dart b/turms-chat-demo-flutter/lib/domain/conversation/view_models/id_to_conversation_settings_view_model.dart new file mode 100644 index 0000000000..10360e7c34 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/conversation/view_models/id_to_conversation_settings_view_model.dart @@ -0,0 +1,40 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../infra/collection/list_holder.dart'; +import '../models/conversation_setting.dart'; +import '../models/conversation_settings.dart'; + +class IdToConversationSettingsViewModelNotifier + extends Notifier> { + void update( + IntListHolder id, { + bool? pinned, + bool? enableNewMessageNotification, + }) { + var settings = state[id]; + if (settings == null) { + settings = ConversationSettings({ + ConversationSetting.pinned: pinned, + ConversationSetting.enableNewMessageNotification: + enableNewMessageNotification, + }); + state[id] = settings; + } else { + if (pinned != null) { + settings.pinned = pinned; + } + if (enableNewMessageNotification != null) { + settings.enableNewMessageNotification = enableNewMessageNotification; + } + } + ref.notifyListeners(); + } + + @override + Map build() => {}; +} + +final idToConversationSettingsViewModel = NotifierProvider< + IdToConversationSettingsViewModelNotifier, + Map>( + IdToConversationSettingsViewModelNotifier.new); diff --git a/turms-chat-demo-flutter/lib/domain/file/fixtures/files.dart b/turms-chat-demo-flutter/lib/domain/file/fixtures/files.dart new file mode 100644 index 0000000000..27920d95f3 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/file/fixtures/files.dart @@ -0,0 +1,69 @@ +import '../../common/fixtures/fixtures.dart'; +import '../models/file_info.dart'; + +final _files = [ + FileInfo( + name: 'cat.gif', + uploadDate: DateTime.now(), + uploader: 'myself', + type: 'gif', + size: 7832457), + FileInfo( + name: 'dog.gif', + uploadDate: DateTime.now(), + uploader: 'myself', + type: 'gif', + size: 123456), + FileInfo( + name: 'cat.png', + uploadDate: DateTime.now(), + uploader: 'myself', + type: 'png', + size: 4452), + FileInfo( + name: 'dog.png', + uploadDate: DateTime.now(), + uploader: 'myself', + type: 'png', + size: 7831237), + FileInfo( + name: 'cat.jpg', + uploadDate: DateTime.now(), + uploader: 'myself', + type: 'jpg', + size: 123786), + FileInfo( + name: 'dog.jpg', + uploadDate: DateTime.now(), + uploader: 'myself', + type: 'jpg', + size: 879453), + FileInfo( + name: 'cat.pdf', + uploadDate: DateTime.now(), + uploader: 'myself', + type: 'pdf', + size: 8735437454), + FileInfo( + name: 'dog.pdf', + uploadDate: DateTime.now(), + uploader: 'myself', + type: 'pdf', + size: 12345378), + FileInfo( + name: 'cat.doc', + uploadDate: DateTime.now(), + uploader: 'myself', + type: 'doc', + size: 651378), + FileInfo( + name: 'dog.doc', + uploadDate: DateTime.now(), + uploader: 'myself', + type: 'doc', + size: 783), +]; + +extension FixturesExtensions on Fixtures { + List get files => _files; +} diff --git a/turms-chat-demo-flutter/lib/domain/file/models/file_info.dart b/turms-chat-demo-flutter/lib/domain/file/models/file_info.dart new file mode 100644 index 0000000000..b632ebf16c --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/file/models/file_info.dart @@ -0,0 +1,14 @@ +class FileInfo { + FileInfo( + {required this.name, + required this.uploadDate, + required this.uploader, + required this.type, + required this.size}); + + final String name; + final DateTime uploadDate; + final String uploader; + final String type; + final int size; +} diff --git a/turms-chat-demo-flutter/lib/domain/file/services/file_service.dart b/turms-chat-demo-flutter/lib/domain/file/services/file_service.dart new file mode 100644 index 0000000000..4ed26c4fe7 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/file/services/file_service.dart @@ -0,0 +1,21 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../common/fixtures/fixtures.dart'; +import '../../user/models/user.dart'; +import '../fixtures/files.dart'; +import '../models/file_info.dart'; + +class FileService { + const FileService(this._loggedInUser); + + final User _loggedInUser; + + Future> queryFiles() async { + await Future.delayed(const Duration(seconds: 3)); + return Fixtures.instance.files; + } +} + +final fileServiceProvider = StateProvider( + (ref) => null, +); diff --git a/turms-chat-demo-flutter/lib/domain/group/fixtures/group_membership_requests.dart b/turms-chat-demo-flutter/lib/domain/group/fixtures/group_membership_requests.dart new file mode 100644 index 0000000000..f44dce0b64 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/group/fixtures/group_membership_requests.dart @@ -0,0 +1,31 @@ +import 'package:fixnum/fixnum.dart'; + +import '../../common/fixtures/fixtures.dart'; +import '../../common/models/request_status.dart'; +import '../../user/models/user.dart'; +import '../models/group.dart'; +import '../models/group_membership_request.dart'; + +final _now = DateTime.now(); + +final _groupMembershipRequests = [ + GroupMembershipRequest( + id: Int64(22), + status: RequestStatus.pending, + sender: User(userId: Int64(22), name: 'fake name'), + creationDate: _now.subtract(const Duration(days: 365)), + message: 'hi', + group: Group(id: Int64(22), name: 'fake name')), + GroupMembershipRequest( + id: Int64(23), + status: RequestStatus.accepted, + sender: User(userId: Int64(23), name: 'fake name'), + creationDate: _now.subtract(const Duration(days: 365)), + message: 'a very long message. ' * 50, + group: Group(id: Int64(23), name: 'fake name')), +]; + +extension FixturesExtensions on Fixtures { + List get groupMembershipRequests => + _groupMembershipRequests; +} diff --git a/turms-chat-demo-flutter/lib/domain/group/models/group.dart b/turms-chat-demo-flutter/lib/domain/group/models/group.dart new file mode 100644 index 0000000000..38103b1cd4 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/group/models/group.dart @@ -0,0 +1,8 @@ +import 'package:fixnum/fixnum.dart'; + +class Group { + Group({required this.id, required this.name}); + + final Int64 id; + final String name; +} diff --git a/turms-chat-demo-flutter/lib/domain/group/models/group_membership_request.dart b/turms-chat-demo-flutter/lib/domain/group/models/group_membership_request.dart new file mode 100644 index 0000000000..08b91256fb --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/group/models/group_membership_request.dart @@ -0,0 +1,24 @@ +import '../../common/models/new_relationship_request.dart'; +import '../../common/models/request_status.dart'; +import 'group.dart'; + +class GroupMembershipRequest extends NewRelationshipRequest { + GroupMembershipRequest( + {required super.id, + required super.status, + required super.sender, + required super.creationDate, + required super.message, + required this.group}); + + final Group group; + + GroupMembershipRequest copyWith({required RequestStatus status}) => + GroupMembershipRequest( + id: id, + status: status, + sender: sender, + creationDate: creationDate, + message: message, + group: group); +} diff --git a/turms-chat-demo-flutter/lib/domain/group/services/group_service.dart b/turms-chat-demo-flutter/lib/domain/group/services/group_service.dart new file mode 100644 index 0000000000..f418a7be67 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/group/services/group_service.dart @@ -0,0 +1,35 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../user/models/contact.dart'; + +class GroupService { + Future approveGroupMembershipRequest(Int64 id) async { + await Future.delayed(const Duration(seconds: 3)); + } + + Future> searchGroupContacts( + Int64 num, String value) async { + await Future.delayed(const Duration(seconds: 3)); + return [ + GroupContact( + groupId: num, + name: 'a fake group name: $value' * 10, + intro: 'a fake group intro', + members: [], + ) + ]; + } + + Future createGroup() async { + await Future.delayed(const Duration(seconds: 3)); + } + + Future updateGroupName(String name) async { + await Future.delayed(const Duration(seconds: 3)); + } +} + +final groupServiceProvider = StateProvider( + (ref) => null, +); diff --git a/turms-chat-demo-flutter/lib/domain/message/models/message_delivery_status.dart b/turms-chat-demo-flutter/lib/domain/message/models/message_delivery_status.dart new file mode 100644 index 0000000000..9186d4f48f --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/message/models/message_delivery_status.dart @@ -0,0 +1 @@ +enum MessageDeliveryStatus { delivering, delivered, failed } diff --git a/turms-chat-demo-flutter/lib/domain/message/models/message_group.dart b/turms-chat-demo-flutter/lib/domain/message/models/message_group.dart new file mode 100644 index 0000000000..eb24b0174a --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/message/models/message_group.dart @@ -0,0 +1,7 @@ +import '../../../ui/desktop/pages/home_page/chat_page/chat_session_pane/message.dart'; + +extension type MessageGroup(List messages) { + void addMessage(ChatMessage message) { + messages.add(message); + } +} diff --git a/turms-chat-demo-flutter/lib/domain/message/models/message_info.dart b/turms-chat-demo-flutter/lib/domain/message/models/message_info.dart new file mode 100644 index 0000000000..c7ebbda218 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/message/models/message_info.dart @@ -0,0 +1,29 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:markdown/markdown.dart'; + +import 'message_type.dart'; + +class MessageInfo { + const MessageInfo( + {required this.type, + required this.nodes, + this.originalUrl, + this.originalWidth, + this.originalHeight, + this.mentionAll, + this.mentionedUserIds}); + + final MessageType type; + + final List nodes; + + // TODO: no turms server support generate thumbnail yet, + // so there is no point to use thumbnailUrl currently. + // final String? thumbnailUrl; + final String? originalUrl; + final double? originalWidth; + final double? originalHeight; + + final bool? mentionAll; + final Set? mentionedUserIds; +} diff --git a/turms-chat-demo-flutter/lib/domain/message/models/message_type.dart b/turms-chat-demo-flutter/lib/domain/message/models/message_type.dart new file mode 100644 index 0000000000..1d550b6155 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/message/models/message_type.dart @@ -0,0 +1,8 @@ +enum MessageType { + text, + file, + image, + video, + audio, + youtube, +} diff --git a/turms-chat-demo-flutter/lib/domain/message/repositories/message_repository.dart b/turms-chat-demo-flutter/lib/domain/message/repositories/message_repository.dart new file mode 100644 index 0000000000..7edfe29d72 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/message/repositories/message_repository.dart @@ -0,0 +1,298 @@ +import 'package:drift/drift.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../infra/sqlite/user_message_database.dart'; +import '../../../ui/desktop/pages/home_page/chat_page/chat_session_pane/message.dart'; +import '../models/message_type.dart'; + +final _expressionCountAll = countAll(); + +class MessageRepository { + MessageRepository(this._userMessageDatabase); + + final UserMessageDatabase _userMessageDatabase; + + Future upsertMessages({ + required List messages, + }) async { + final messagesToInsert = messages.map((message) { + final isGroupMessage = message.isGroupMessage; + return _getMessageTableCompanion( + message.messageId, + isGroupMessage, + isGroupMessage ? message.groupId! : message.recipientId!, + message.senderId, + message.timestamp, + message.text, + message.records, + message.type); + }).toList(); + await _userMessageDatabase.batch((batch) { + batch.insertAll(_userMessageDatabase.messageTable, messagesToInsert, + mode: InsertMode.insertOrReplace); + }); + } + + Future upsertMessage({ + required ChatMessage message, + }) async { + final isGroupMessage = message.isGroupMessage; + return upsert( + messageId: message.messageId, + isGroupMessage: isGroupMessage, + toId: isGroupMessage ? message.groupId! : message.recipientId!, + senderId: message.senderId, + messageType: message.type, + text: message.text, + records: message.records, + createdDate: message.timestamp); + } + + Future upsert({ + required Int64 messageId, + required bool isGroupMessage, + required Int64 toId, + required Int64 senderId, + required MessageType messageType, + String? text, + List? records, + required DateTime createdDate, + }) async { + await _userMessageDatabase + .into(_userMessageDatabase.messageTable) + .insertOnConflictUpdate(_getMessageTableCompanion( + messageId, + isGroupMessage, + toId, + senderId, + createdDate, + text, + records, + messageType)); + } + + MessageTableCompanion _getMessageTableCompanion( + Int64 messageId, + bool isGroupMessage, + Int64 contactId, + Int64 senderId, + DateTime createdDate, + String? text, + List? records, + MessageType messageType) => + MessageTableCompanion.insert( + id: messageId, + // groupId: groupId == null ? const Value.absent() : Value(groupId), + isGroupMessage: isGroupMessage, + contactId: contactId, + senderId: senderId, + createdDate: createdDate, + txt: text == null ? const Value.absent() : Value(text), + records: records == null ? const Value.absent() : Value(records), + type: messageType, + ); + + Future delete({ + List? ids, + Int64? idEnd, + Int64? groupId, + List? participantIds, + }) async { + final tbl = _userMessageDatabase.messageTable; + final statement = _userMessageDatabase.delete(tbl) + ..where((t) => Expression.and([ + if (ids != null) t.id.isIn(ids.map((e) => e.toBigInt()).toList()), + if (idEnd != null) t.id.isSmallerThanValue(idEnd.toBigInt()), + if (groupId != null) ...[ + tbl.contactId.equalsValue(groupId), + tbl.isGroupMessage.equals(true), + ] else if (participantIds != null) + Expression.or([ + Expression.and([ + tbl.contactId.equalsValue(participantIds[0]), + tbl.senderId.equalsValue(participantIds[1]), + tbl.isGroupMessage.equals(false), + ]), + Expression.and([ + tbl.contactId.equalsValue(participantIds[1]), + tbl.senderId.equalsValue(participantIds[0]), + tbl.isGroupMessage.equals(false), + ]) + ]), + ])); + return statement.go(); + } + + Future> countAndSearchLatestMessage({ + Int64? idStart, + Int64? senderId, + Int64? groupId, + List? participantIds, + String? text, + MessageType? messageType, + DateTime? createdDateStart, + DateTime? createdDateEnd, + required int limit, + }) async { + final messageTable = _userMessageDatabase.messageTable; + final select = messageTable.select().addColumns([ + _expressionCountAll, + ]) + ..groupBy([ + messageTable.contactId, + messageTable.isGroupMessage, + ]) + ..where( + _buildExpressionGroupIdOrParticipantIds( + messageTable, + idStart, + groupId, + participantIds, + text, + messageType, + createdDateStart, + createdDateEnd), + ) + ..orderBy( + [ + OrderingTerm( + expression: messageTable.createdDate, mode: OrderingMode.desc), + // Order by id to ensure that the messages are in a consistent order + // even the timestamp of the messages is the same. + OrderingTerm(expression: messageTable.id) + ], + ) + ..limit(limit); + final records = await select.get(); + return records.map((record) { + final message = record.readTable(messageTable); + final count = record.read(_expressionCountAll)!; + return CountAndSearchLatestMessageResult( + message: message, + count: count, + ); + }).toList(); + } + + Future count({ + Int64? idStart, + Int64? senderId, + Int64? groupId, + List? participantIds, + String? text, + MessageType? messageType, + DateTime? createdDateStart, + DateTime? createdDateEnd, + required int limit, + }) async { + assert(groupId == null + ? (participantIds != null && participantIds.isNotEmpty) + : (participantIds == null || participantIds.isEmpty)); + final messageTable = _userMessageDatabase.messageTable; + final count = messageTable.count( + where: (tbl) => _buildExpressionGroupIdOrParticipantIds( + tbl, + idStart, + groupId, + participantIds, + text, + messageType, + createdDateStart, + createdDateEnd), + ); + return count.getSingle(); + } + + Future> selectMessages({ + Int64? idStart, + Int64? senderId, + Int64? groupId, + List? participantIds, + String? text, + MessageType? messageType, + DateTime? createdDateStart, + DateTime? createdDateEnd, + required int limit, + }) async { + assert(groupId == null + ? (participantIds != null && participantIds.isNotEmpty) + : (participantIds == null || participantIds.isEmpty)); + final statement = + _userMessageDatabase.select(_userMessageDatabase.messageTable) + ..where( + (tbl) => _buildExpressionGroupIdOrParticipantIds( + tbl, + idStart, + groupId, + participantIds, + text, + messageType, + createdDateStart, + createdDateEnd), + ) + ..orderBy( + [ + (record) => OrderingTerm( + expression: record.createdDate, mode: OrderingMode.desc), + (record) => + // Order by id to ensure that the messages are in a consistent order + // even the timestamp of the messages is the same. + OrderingTerm(expression: record.id) + ], + ) + ..limit(limit); + return statement.get(); + } + + Future> selectAll() => + _userMessageDatabase.select(_userMessageDatabase.messageTable).get(); + + Expression _buildExpressionGroupIdOrParticipantIds( + $MessageTableTable tbl, + Int64? idStart, + Int64? groupId, + List? participantIds, + String? text, + MessageType? messageType, + DateTime? createdDateStart, + DateTime? createdDateEnd) => + Expression.and([ + if (idStart != null) tbl.id.isBiggerThanValue(idStart.toBigInt()), + if (groupId != null) ...[ + tbl.contactId.equalsValue(groupId), + tbl.isGroupMessage.equals(true), + ] else if (participantIds != null) + Expression.or([ + Expression.and([ + tbl.contactId.equalsValue(participantIds[0]), + tbl.senderId.equalsValue(participantIds[1]), + tbl.isGroupMessage.equals(false), + ]), + Expression.and([ + tbl.contactId.equalsValue(participantIds[1]), + tbl.senderId.equalsValue(participantIds[0]), + tbl.isGroupMessage.equals(false), + ]) + ]), + if (text != null) tbl.txt.contains(text), + if (messageType != null) tbl.type.equalsValue(messageType), + if (createdDateStart != null) + tbl.createdDate.isBiggerOrEqualValue(createdDateStart), + if (createdDateEnd != null) + tbl.createdDate.isSmallerOrEqualValue(createdDateEnd), + ]); +} + +class CountAndSearchLatestMessageResult { + const CountAndSearchLatestMessageResult( + {required this.message, required this.count}); + + final MessageTableData message; + final int count; +} + +final messageRepositoryProvider = StateProvider( + (ref) => null, +); diff --git a/turms-chat-demo-flutter/lib/domain/message/services/message_service.dart b/turms-chat-demo-flutter/lib/domain/message/services/message_service.dart new file mode 100644 index 0000000000..6a91e7bad9 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/message/services/message_service.dart @@ -0,0 +1,230 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:markdown/markdown.dart'; + +import '../../../infra/markdown/mention_syntax.dart'; +import '../../../infra/markdown/resource_syntax.dart'; +import '../../../ui/desktop/pages/home_page/chat_page/chat_session_pane/message.dart'; +import '../models/message_info.dart'; +import '../models/message_type.dart'; +import '../repositories/message_repository.dart'; + +final _document = Document( + withDefaultBlockSyntaxes: false, + withDefaultInlineSyntaxes: false, + encodeHtml: false, + inlineSyntaxes: [ResourceSyntax(), MentionSyntax()]); + +class MessageService { + const MessageService(this._messageRepository); + + final MessageRepository _messageRepository; + + /// Note that we reference Markdown's syntax, but we don't follow it exactly. e.g.: + /// we use "![http://example.com/thumbnail.png|100x100](http://example.com/video.mp4)" instead of + /// "[![|100x100](http://example.com/thumbnail.png)](http://example.com/video.mp4)" to represent video + /// as the previous one is concise and consistent with our use cases. + /// + /// Image: "![http://example.com/thumbnail.png|100x100](http://example.com/original_image.png)" + /// Audio: "![http://example.com/thumbnail.png|100x100](http://example.com/video.mp3)" + /// Video: "![http://example.com/thumbnail.png|100x100](http://example.com/video.mp4)" + static MessageInfo parseMessageInfo(String? text) { + if (text == null || text.isEmpty) { + return const MessageInfo(type: MessageType.text, nodes: []); + } + final nodes = _document.parseInline(text); + final nodeCount = nodes.length; + assert(nodeCount > 0, 'Invalid message text'); + if (nodeCount == 1) { + final firstNode = nodes.first; + if (firstNode case final Element element) { + if (element.tag == ResourceSyntax.tag) { + return _parseResourceOrTextMessage(nodes, element); + } + } + return _parseTextMessageFromNode(nodes, firstNode); + } + return _parseTextMessageFromNodes(nodes); + } + + static MessageInfo _parseResourceOrTextMessage( + List nodes, Element element) { + final src = element.attributes[ResourceSyntax.attributeSrc]; + if (src == null) { + return _parseTextMessageFromNode(nodes, element); + } + final alt = element.attributes[ResourceSyntax.attributeAlt]; + if (alt == null) { + return _parseTextMessageFromNode(nodes, element); + } + if (src.contains('//www.youtube.com/') || src.contains('//youtube.com/')) { + return MessageInfo( + type: MessageType.youtube, + originalUrl: src, + nodes: nodes, + ); + } else if (src.endsWith('.mp4') || + src.endsWith('.mov') || + src.endsWith('.avi')) { + final sizeDividerIndex = alt.lastIndexOf('|'); + if (sizeDividerIndex < 0) { + return _parseTextMessageFromNode(nodes, element); + } + final xIndex = alt.lastIndexOf('x'); + if (xIndex < 0 || xIndex <= sizeDividerIndex) { + return _parseTextMessageFromNode(nodes, element); + } + final width = + double.tryParse(alt.substring(sizeDividerIndex + 1, xIndex)); + if (width == null) { + return _parseTextMessageFromNode(nodes, element); + } + final height = double.tryParse(alt.substring(xIndex + 1)); + if (height == null) { + return _parseTextMessageFromNode(nodes, element); + } + return MessageInfo( + type: MessageType.video, + originalUrl: src, + originalHeight: height.floorToDouble(), + originalWidth: width.floorToDouble(), + nodes: nodes, + ); + } else if (src.endsWith('.mp3') || src.endsWith('.wav')) { + return MessageInfo( + type: MessageType.audio, + originalUrl: src, + nodes: nodes, + ); + } else if (_isSupportedImageType(src)) { + final sizeDividerIndex = alt.lastIndexOf('|'); + if (sizeDividerIndex < 0) { + return _parseTextMessageFromNode(nodes, element); + } + final xIndex = alt.indexOf('x', sizeDividerIndex + 1); + if (xIndex < 0) { + return _parseTextMessageFromNode(nodes, element); + } + final width = + double.tryParse(alt.substring(sizeDividerIndex + 1, xIndex)); + if (width == null) { + return _parseTextMessageFromNode(nodes, element); + } + final height = double.tryParse(alt.substring(xIndex + 1)); + if (height == null) { + return _parseTextMessageFromNode(nodes, element); + } + return MessageInfo( + type: MessageType.image, + originalUrl: src, + originalHeight: height.floorToDouble(), + originalWidth: width.floorToDouble(), + nodes: nodes, + ); + } else { + return MessageInfo( + type: MessageType.file, + originalUrl: src, + nodes: nodes, + ); + } + } + + static MessageInfo _parseTextMessageFromNodes(List nodes) { + var mentionAll = false; + final mentionedUserIds = {}; + for (final node in nodes) { + if (node is Element && node.tag == MentionSyntax.tag) { + final value = node.attributes.values.first; + if (value == MentionSyntax.mentionAllValue) { + mentionAll = true; + } else { + if (Int64.tryParseInt(value) case final Int64 userId) { + mentionedUserIds.add(userId); + } + } + } + } + return MessageInfo( + type: MessageType.text, + nodes: nodes, + mentionAll: mentionAll, + mentionedUserIds: mentionedUserIds); + } + + static MessageInfo _parseTextMessageFromNode(List nodes, Node node) { + var mentionAll = false; + final mentionedUserIds = {}; + if (node is Element && node.tag == MentionSyntax.tag) { + final value = node.attributes.values.first; + if (value == MentionSyntax.mentionAllValue) { + mentionAll = true; + } else { + final userId = Int64.tryParseInt(value); + if (userId != null) { + mentionedUserIds.add(userId); + } + } + } + return MessageInfo( + type: MessageType.text, + nodes: nodes, + mentionAll: mentionAll, + mentionedUserIds: mentionedUserIds); + } + + static bool _isSupportedImageType(String originalUrl) => + originalUrl.endsWith('.png') || + originalUrl.endsWith('.jpg') || + originalUrl.endsWith('.jpeg') || + originalUrl.endsWith('.gif') || + originalUrl.endsWith('.webp'); + + static String encodeImageMessage( + {required String originalUrl, + required String thumbnailUrl, + required int width, + required int height}) => + '![$thumbnailUrl|${width}x$height]($originalUrl)'; + + Future sendMessage(String text, ChatMessage message) async { + await Future.delayed(const Duration(seconds: 1)); + return message; + } + + Future> queryMoreMessages() async { + await Future.delayed(const Duration(seconds: 2)); + // final messageTexts = List.generate( + // 10, (index) => 'New Message $index'); + return List.empty(); + } + + Future> searchMessages({ + required Int64 loggedInUserId, + Int64? idStart, + Int64? groupId, + List? participantIds, + String? text, + MessageType? messageType, + DateTime? createdDateStart, + DateTime? createdDateEnd, + required int limit, + }) async { + final messageRecords = await _messageRepository.selectMessages( + idStart: idStart, + groupId: groupId, + participantIds: participantIds, + text: text, + messageType: messageType, + createdDateStart: createdDateStart, + createdDateEnd: createdDateEnd, + limit: limit); + return messageRecords + .map( + (e) => ChatMessage.fromMessageTableData(e, loggedInUserId), + ) + .toList(); + } +} + +final messageServiceProvider = StateProvider((ref) => null); diff --git a/turms-chat-demo-flutter/lib/domain/message/tables/message_table.dart b/turms-chat-demo-flutter/lib/domain/message/tables/message_table.dart new file mode 100644 index 0000000000..e9574427bf --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/message/tables/message_table.dart @@ -0,0 +1,42 @@ +import 'package:drift/drift.dart'; + +import '../../../infra/sqlite/converter/int64_converter.dart'; +import '../../../infra/sqlite/converter/uint8_matrix_converter.dart'; +import '../models/message_type.dart'; + +@TableIndex(name: 'sender_id', columns: {#senderId}) +@TableIndex(name: 'contact_id', columns: {#contactId}) +@TableIndex(name: 'created_date', columns: {#createdDate}) +class MessageTable extends Table { + late final id = int64().map(const Int64Converter())(); + + late final isGroupMessage = boolean()(); + + late final senderId = int64().map(const Int64Converter())(); + + /// 1. This can be either a group ID or a user (recipient) ID. + /// 2. We use [contactId] instead of the columns [groupId] and [recipientId], + /// so we don't need to creat two indexes. + late final contactId = int64().map(const Int64Converter())(); + + @JsonKey('text') + late final txt = text().named('text').nullable()(); + + late final records = blob().map(const Uint8MatrixConverter()).nullable()(); + + late final type = intEnum()(); + + late final createdDate = dateTime()(); + + @override + String get tableName => 'message'; + + @override + Set get primaryKey => {id}; + + @override + bool get withoutRowId => true; + + @override + bool get isStrict => true; +} diff --git a/turms-chat-demo-flutter/lib/domain/user/fixtures/contacts.dart b/turms-chat-demo-flutter/lib/domain/user/fixtures/contacts.dart new file mode 100644 index 0000000000..d2ab350af8 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/fixtures/contacts.dart @@ -0,0 +1,216 @@ +import 'package:fixnum/fixnum.dart'; + +import '../../../infra/random/random_utils.dart'; +import '../../../ui/desktop/components/t_avatar/t_avatar.dart'; +import '../../common/fixtures/fixtures.dart'; +import '../models/group_member.dart'; +import '../models/index.dart'; + +class _RawContactConversation { + const _RawContactConversation(this.userName, this.messages); + + final String userName; + + final List messages; +} + +List<_RawContactConversation> _contactConversations = [ + // Earth + const _RawContactConversation('Murmurs of Earth', [ + 'Greetings in 55 languages', + '𒁲𒈠𒃶𒈨𒂗', + 'Οἵτινές ποτ᾿ ἔστε χαίρετε! Εἰρηνικῶς πρὸς φίλους ἐληλύθαμεν φίλοι.', + 'Paz e felicidade a todos', + '各位好嗎?祝各位平安健康快樂。', + 'Adanniš lu šulmu', + 'Здравствуйте! Приветствую Вас!', + 'สวัสดีค่ะ สหายในธรณีโพ้น พวกเราในธรณีนี้ขอส่งมิตรจิตมาถึงท่านทุกคน', + '.تحياتنا للأصدقاء في النجوم. يا ليت يجمعنا الزمان', + 'Salutări la toată lumea', + 'Bonjour tout le monde', + 'နေကောင်းပါသလား', + 'שלום', + 'Hola y saludos a todos', + 'Selamat malam hadirin sekalian, selamat berpisah dan sampai bertemu lagi dilain waktu', + 'Kay pachamamta niytapas maytapas rimapallasta runasimipi', + 'ਆਓ ਜੀ, ਜੀ ਆਇਆਂ ਨੂੰ', + 'aššuli', + 'নমস্কার, বিশ্বে শান্তি হোক', + 'Salvete quicumque estis; bonam erga vos voluntatem habemus, et pacem per astra ferimus', + '𐡌𐡋𐡔 or שלם or ܫܠܡ Šəlām', + 'Hartelijke groeten aan iedereen', + 'Herzliche Grüße an alle', + 'السلام عليکم ـ ہم زمين کے رہنے والوں کى طرف سے آپ کو خوش آمديد کہتے ھيں', + 'Chân thành gửi tới các bạn lời chào thân hữu', + 'Sayın Türkçe bilen arkadaşlarımız, sabah şerifleriniz hayrolsun', + 'こんにちは。お元気ですか?', + 'धरती के वासियों की ओर से नमस्कार', + 'Iechyd da i chi yn awr, ac yn oesoedd', + 'Tanti auguri e saluti', + ' ආයුබෝවන්!', + 'Siya nibingelela maqhawe sinifisela inkonzo ende.', + 'Reani lumelisa marela.', + '祝㑚大家好。', + 'Բոլոր անոնց որ կը գտնուին տիեզերգի միգամածութիւնէն անդին, ողջոյններ', + '안녕하세요', + 'Witajcie, istoty z zaświatów.', + 'प्रिथ्वी वासीहरु बाट शान्ति मय भविष्य को शुभकामना', + '各位都好吧?我们都很想念你们,有空请到这儿来玩。', + 'Mypone kaboutu noose.', + 'Hälsningar från en dataprogrammerare i den lilla universitetsstaden Ithaca på planeten Jorden', + 'Mulibwanji imwe boonse bantu bakumwamba.', + 'પૃથ્વી ઉપર વસનાર એક માનવ તરફથી બ્રહ્માંડના અન્ય અવકાશમાં વસનારાઓને હાર્દિક અભિનંદન. આ સંદેશો મળ્યે, વળતો સંદેશો મોકલાવશો.', + "Пересилаємо привіт із нашого світу, бажаємо щастя, здоров'я і многая літа", + 'درود بر ساکنین ماورای آسمان‌ها', + 'Желимо вам све најлепше са наше планете', + 'ସୂର୍ଯ୍ୟ ତାରକାର ତୃତୀୟ ଗ୍ରହ ପୃଥିବୀରୁ ବିଶ୍ୱବ୍ରହ୍ମାଣ୍ଡର ଅଧିବାସୀ ମାନଙ୍କୁ ଅଭିନନ୍ଦନ', + 'Musulayo mutya abantu bensi eno mukama abawe emirembe bulijo.', + 'नमस्कार. ह्या पृथ्वीतील लोक तुम्हाला त्यांचे शुभविचार पाठवतात आणि त्यांची इच्छा आहे की तुम्ही ह्या जन्मी धन्य व्हा.', + '太空朋友,恁好!恁食飽未?有閒著來阮遮坐喔。', + 'Üdvözletet küldünk magyar nyelven minden békét szerető lénynek a Világegyetemen', + 'నమస్తే, తెలుగు మాట్లాడే జనముననించి మా శుభాకాంక్షలు.', + 'Milí přátelé, přejeme vám vše nejlepší', + 'ನಮಸ್ತೆ, ಕನ್ನಡಿಗರ ಪರವಾಗಿ ಶುಭಾಷಯಗಳು.', + '-', + 'Hello from the children of planet Earth', + '![Solar System Portrait|4000x1200](https://voyager.jpl.nasa.gov/assets/images/galleries/images-voyager-took/solar-system-portrait/PIA00451.jpg)' + ]), + // China + const _RawContactConversation('窦唯', [ + '暮春秋色', + '多开阔', + '幻声凋落', + '曙分', + '云舞', + '冬穿梭', + '往来经过', + '手挥', + '捕捉', + '起风了', + '骤雨夏天', + '暮春', + '秋色', + '一清池', + '姽婳妩媚', + '万丘壑', + '锦缎绫罗', + '惑多', + '已消落', + '光阴归来', + '变空白', + '染尘埃', + '一并敛埋' + ]), + // America + const _RawContactConversation('Nina Simone', [ + '![Nina Simone - Stars / Feelings (Medley / Live at Montreux, 1976)](https://www.youtube.com/watch?v=Mf_5l1yTKNY)', + '![butterfly|1280x720](https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4)' + ]), + // Brazil + const _RawContactConversation('Elis Regina', [ + 'Como Nossos Pais', + '![](https://flutter.github.io/assets-for-api-docs/assets/audio/rooster.mp3)' + ]), + // Cambodia + const _RawContactConversation('រស់ សេរីសុទ្ធា', [ + 'បើបងស្រឡាញ់ខ្ញុំ', + 'រស់ សេរីសុទ្ធា និង អឹម សឺុងសឹម', + 'រៀបរាងដោយ', + '(ស្រី) បើ បងប្រាថ្នាចង់ស្រលាញ់ខ្ញុំ', + 'កុំ សើចកុំយំណា៎បងណា៎', + 'អូន ត្រូវសាកល្បង តាមត្រូវ ការ', + 'ឱ្យធ្វើយ៉ាងណា ធ្វើយ៉ាងណា', + 'កុំប្រកែកឡើយ។', + 'បើ បងប្រាថ្នាចង់ស្រលាញ់ខ្ញុំ', + 'កុំ សើចកុំយំ ត្រូវគិតឱ្យហើយ', + 'ក្រែង លោបងជូន', + 'អូនមិនបានដល់ត្រើយ', + 'តិចលោបកក្រោយ', + 'ចោលឱ្យអូន នៅកណ្ដាលផ្លូវ។', + '(ប្រុសបន្ទរ) គ្មានបញ្ហា ចោទថា', + 'បងទ្រាំ មិនបាន ទេស្រី', + 'ចុះទឹក ក្រពើ ឡើងលើ ខ្លាត្រី', + 'ក៏បង ព្រមដែរ។', + '(ស្រី) បើ បងប្រាថ្នាចង់ស្រលាញ់ខ្ញុំ', + 'កុំ សើចកុំយំ កុំត្អូញត្អែរ', + 'និ យាយដូចបង ក៏ល្មមព្រមដែរ', + 'គួររកគ្រូស្នេហ៍', + 'ដាក់ថ្នមថែរអស់មួយជីវិត។', + '(ស្រី) បើ បងប្រាថ្នាចង់ស្រលាញ់ខ្ញុំ', + 'កុំ សើចកុំយំណា៎បងណា៎', + 'អូន ត្រូវសាកល្បង តាមត្រូវ ការ', + 'ឱ្យធ្វើយ៉ាងណា ធ្វើយ៉ាងណា', + 'កុំប្រកែកឡើយ។', + 'បើ បងប្រាថ្នាចង់ស្រលាញ់ខ្ញុំ', + 'កុំ សើចកុំយំ ត្រូវគិតឱ្យហើយ', + 'ក្រែង លោបងជូន', + 'អូនមិនបានដល់ត្រើយ', + 'តិចលោបកក្រោយ', + 'ចោលឱ្យអូន នៅកណ្ដាលផ្លូវ។', + '(ប្រុសបន្ទរ) គ្មានបញ្ហា ចោទថា', + 'បងទ្រាំ មិនបាន ទេស្រី', + 'ចុះទឹក ក្រពើ ឡើងលើ ខ្លាត្រី', + 'ក៏បង ព្រមដែរ។', + '(ស្រី) បើ បងប្រាថ្នាចង់ស្រលាញ់ខ្ញុំ', + 'កុំ សើចកុំយំ កុំត្អូញត្អែរ', + 'និ យាយដូចបង ក៏ល្មមព្រមដែរ', + 'គួររកគ្រូស្នេហ៍', + 'ដាក់ថ្នមថែរអស់មួយជីវិត។', + 'កុំ សើចកុំយំ កុំត្អូញត្អែរ', + 'កុំ សើចកុំយំ កុំត្អូញត្អែរ', + 'បើ បងប្រាថ្នាចង់ស្រលាញ់ខ្ញុំ', + 'បើ បងប្រាថ្នាចង់ស្រលាញ់ខ្ញុំ', + 'បើ បងប្រាថ្នាចង់ស្រលាញ់ខ្ញុំ', + 'បើ បងប្រាថ្នាចង់ស្រលាញ់ខ្ញុំ។', + 'ដោយក្តីស្រឡាញ់ពីខ្ញុំ ស៊ិន ស៊ីតារា' + ]), + // Italy + const _RawContactConversation('Piero Piccioni', ['Amore Mio Aiutami']), + // South Korea + const _RawContactConversation('MC몽', [ + '![MC MONG MC몽 ‘죽을 만큼 아파서 (Feat. JAMIE (제이미))’ Live Performance](https://www.youtube.com/watch?v=FdAIE6Z4S9k)', + '![404](https://a-wrong-url.com/404.png)' + ]), + // Japan + const _RawContactConversation('Nujabes', ['Aruarian Dance']), +]; + +final _userContacts = _contactConversations.indexed.map((item) { + final (index, contactConversation) = item; + return UserContact( + userId: Int64(index + 1), + name: contactConversation.userName, + relationshipGroupId: Int64(1), + presence: + UserPresence.values[1 + (index % (UserPresence.values.length - 1))]); +}).toList(); + +final contactIdToMessages = >{ + for (final userContact in _userContacts) + userContact.userId: _contactConversations + .firstWhere((item) => item.userName == userContact.name) + .messages +}; + +extension FixturesExtensions on Fixtures { + List get userContacts => _userContacts; + + List getFixtureGroupContacts(User loggedInUser) => + userContacts.indexed.map((item) { + final (index, _) = item; + final members = []; + final memberCount = index + 1; + for (var i = 0; i < memberCount; i++) { + members.add(GroupMember.fromUser(userContacts[i], isAdmin: i == 0)); + } + members.add(GroupMember.fromUser(loggedInUser, + isAdmin: RandomUtils.nextBool())); + return GroupContact( + groupId: Int64(index + 1), + members: members, + name: 'fake group name.' * 10); + }).toList(); + + List getContacts(User loggedInUser) => + [..._userContacts, ...getFixtureGroupContacts(loggedInUser)]; +} diff --git a/turms-chat-demo-flutter/lib/domain/user/fixtures/friend_requests.dart b/turms-chat-demo-flutter/lib/domain/user/fixtures/friend_requests.dart new file mode 100644 index 0000000000..286c02fc17 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/fixtures/friend_requests.dart @@ -0,0 +1,68 @@ +import 'package:fixnum/fixnum.dart'; + +import '../../common/fixtures/fixtures.dart'; +import '../../common/models/request_status.dart'; +import '../models/index.dart'; + +final _now = DateTime.now(); + +final _friendRequests = [ + FriendRequest( + id: Int64(1), + status: RequestStatus.pending, + sender: User(userId: Int64(1), name: 'fake name'), + creationDate: _now, + message: 'hello'), + FriendRequest( + id: Int64(2), + status: RequestStatus.pending, + sender: User(userId: Int64(2), name: 'fake name'), + creationDate: _now, + message: 'hi'), + FriendRequest( + id: Int64(3), + status: RequestStatus.accepted, + sender: User(userId: Int64(3), name: 'fake name'), + creationDate: _now, + message: 'a very long message. ' * 50), + FriendRequest( + id: Int64(11), + status: RequestStatus.pending, + sender: User(userId: Int64(11), name: 'fake name'), + creationDate: _now.subtract(const Duration(days: 15)), + message: 'hello'), + FriendRequest( + id: Int64(12), + status: RequestStatus.pending, + sender: User(userId: Int64(12), name: 'fake name'), + creationDate: _now.subtract(const Duration(days: 15)), + message: 'hi'), + FriendRequest( + id: Int64(13), + status: RequestStatus.accepted, + sender: User(userId: Int64(13), name: 'fake name'), + creationDate: _now.subtract(const Duration(days: 15)), + message: 'a very long message. ' * 50), + FriendRequest( + id: Int64(21), + status: RequestStatus.pending, + sender: User(userId: Int64(21), name: 'fake name'), + creationDate: _now.subtract(const Duration(days: 365)), + message: 'hello'), + FriendRequest( + id: Int64(22), + status: RequestStatus.pending, + sender: User(userId: Int64(22), name: 'fake name'), + creationDate: _now.subtract(const Duration(days: 365)), + message: 'hi'), + FriendRequest( + id: Int64(23), + status: RequestStatus.accepted, + sender: User(userId: Int64(23), name: 'fake name'), + creationDate: _now.subtract(const Duration(days: 365)), + message: 'a very long message. ' * 50), +]; + +extension FixturesExtensions on Fixtures { + List get friendRequests => _friendRequests; +} diff --git a/turms-chat-demo-flutter/lib/domain/user/fixtures/relationship_groups.dart b/turms-chat-demo-flutter/lib/domain/user/fixtures/relationship_groups.dart new file mode 100644 index 0000000000..bec564394f --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/fixtures/relationship_groups.dart @@ -0,0 +1,28 @@ +import '../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../ui/l10n/app_localizations.dart'; +import '../../common/fixtures/fixtures.dart'; +import '../models/relationship_group.dart'; +import '../models/user.dart'; +import 'contacts.dart'; + +final _userRelationshipGroups = Fixtures.instance.userContacts + .groupBy((c) => c.relationshipGroupId) + .entries + .map((entry) => RelationshipGroup.forUser( + id: entry.key!, + name: 'fake-name', + isBlocked: false, + contacts: entry.value)) + .toList(); + +extension FixturesExtensions on Fixtures { + List get userRelationshipGroups => _userRelationshipGroups; + + RelationshipGroup getGroupRelationshipGroup( + User loggedInUser, AppLocalizations appLocalizations) => + RelationshipGroup.forGroup( + name: appLocalizations.groups, + isBlocked: false, + contacts: Fixtures.instance.getFixtureGroupContacts(loggedInUser), + ); +} diff --git a/turms-chat-demo-flutter/lib/domain/user/managers/user_session_manager.dart b/turms-chat-demo-flutter/lib/domain/user/managers/user_session_manager.dart new file mode 100644 index 0000000000..97ae7ec061 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/managers/user_session_manager.dart @@ -0,0 +1,377 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../infra/autostart/autostart_manager.dart'; +import '../../../infra/collection/list_holder.dart'; +import '../../../infra/data/t_async_data.dart'; +import '../../../infra/logging/log_appender_database.dart'; +import '../../../infra/logging/logger.dart'; +import '../../../infra/notification/notification_utils.dart'; +import '../../../infra/random/random_utils.dart'; +import '../../../infra/shortcut/shortcut.dart'; +import '../../../infra/sqlite/user_database.dart'; +import '../../../infra/sqlite/user_message_database.dart'; +import '../../../infra/window/window_utils.dart'; +import '../../../ui/desktop/components/index.dart'; +import '../../../ui/desktop/pages/app.dart'; +import '../../../ui/desktop/pages/home_page/chat_page/chat_session_pane/message.dart'; +import '../../../ui/desktop/pages/home_page/chat_page/view_models/conversations_view_model.dart'; +import '../../../ui/desktop/pages/home_page/chat_page/view_models/selected_conversation_view_model.dart'; +import '../../../ui/desktop/pages/home_page/contacts_page/view_models/contacts_view_model.dart'; +import '../../../ui/desktop/pages/home_page/contacts_page/view_models/relationship_groups_view_model.dart'; +import '../../../ui/desktop/pages/home_page/home_page_action.dart'; +import '../../../ui/l10n/view_models/app_localizations_view_model.dart'; +import '../../app/repositories/app_setting_repository.dart'; +import '../../conversation/models/conversation.dart'; +import '../../conversation/models/conversation_settings.dart'; +import '../../conversation/repositories/conversation_setting_repository.dart'; +import '../../conversation/services/conversation_service.dart'; +import '../../conversation/view_models/id_to_conversation_settings_view_model.dart'; +import '../../file/services/file_service.dart'; +import '../../group/services/group_service.dart'; +import '../../message/models/message_delivery_status.dart'; +import '../../message/models/message_type.dart'; +import '../../message/repositories/message_repository.dart'; +import '../../message/services/message_service.dart'; +import '../models/index.dart'; +import '../models/setting_action_on_close.dart'; +import '../repositories/user_login_info_repository.dart'; +import '../repositories/user_setting_repository.dart'; +import '../services/user_service.dart'; +import '../view_models/logged_in_user_info_view_model.dart'; +import '../view_models/user_settings_view_model.dart'; + +class UserSessionManager { + UserSessionManager({required Int64 userId}) : _loggedInUserId = userId; + + bool _isLoggedIn = true; + final Int64 _loggedInUserId; + + StateController? _loggedInUserController; + StateController? _userSettingsController; + IdToConversationSettingsViewModelNotifier? + _idToConversationSettingsController; + StateController>>? _contactsDataController; + StateController>>? + _relationshipGroupsDataController; + ConversationsDataViewModelNotifier? _conversationsDataController; + SelectedConversationViewModelNotifier? _selectedConversationController; + + Future onLoggedIn( + {required WidgetRef ref, + required bool rememberMe, + required User user, + required String password}) async { + _loggedInUserController = ref.read(loggedInUserViewModel.notifier); + _userSettingsController = ref.read(userSettingsViewModel.notifier); + _idToConversationSettingsController = + ref.read(idToConversationSettingsViewModel.notifier); + _contactsDataController = ref.read(contactsDataViewModel.notifier); + _relationshipGroupsDataController = + ref.read(relationshipGroupsDataViewModel.notifier); + _conversationsDataController = + ref.read(conversationsDataViewModel.notifier); + _selectedConversationController = + ref.read(selectedConversationViewModel.notifier); + + // init repositories + final userId = user.userId; + final userDatabase = createUserDatabaseIfNotExists(userId); + final userMessageDatabase = createUserMessageDatabaseIfNotExists(userId); + final conversationSettingRepository = + ConversationSettingRepository(userDatabase); + final messageRepository = MessageRepository(userMessageDatabase); + ref.read(conversationSettingRepositoryProvider.notifier).state = + conversationSettingRepository; + ref.read(messageRepositoryProvider.notifier).state = messageRepository; + + // init services + final conversationService = ConversationService(user); + final fileService = FileService(user); + final groupService = GroupService(); + final userService = UserService(user); + final messageService = MessageService(messageRepository); + ref.read(conversationServiceProvider.notifier).state = conversationService; + ref.read(fileServiceProvider.notifier).state = fileService; + ref.read(groupServiceProvider.notifier).state = groupService; + ref.read(userServiceProvider.notifier).state = userService; + ref.read(messageServiceProvider.notifier).state = messageService; + + // store app settings + final shouldRemember = rememberMe; + if (shouldRemember) { + await userLoginInfoRepository.upsert(_loggedInUserId, password); + } else { + await userLoginInfoRepository.deleteAll(); + } + final _logAppenderDatabase = LogAppenderDatabase(userId: _loggedInUserId); + logger.addAppender(_logAppenderDatabase); + await appSettingRepository.upsertRememberMe(shouldRemember); + // read user settings + _userSettingsController!.state = await _getUserSettings(); + _idToConversationSettingsController!.state = + await _getIdToConversationSettings(conversationSettingRepository); + _loggedInUserController!.state = user; + unawaited(loadData(conversationService, userService, messageRepository)); + } + + Future _getUserSettings() async { + final userSettingsTableData = + await userSettingRepository.selectAll(_loggedInUserId); + final (userSettings, exception) = + UserSettings.fromTableData(userSettingsTableData); + if (exception != null) { + if (kReleaseMode) { + logger.warn('Failed to read user settings: ${exception.toString()}'); + } else { + throw exception; + } + } + + for (final type in HomePageAction.values) { + switch (type) { + case HomePageAction.showChatPage: + if (!userSettings.shortcutShowChatPage.initialized) { + userSettings.shortcutShowChatPage = + Shortcut(type.defaultShortcutActivator, true); + } + break; + case HomePageAction.showContactsPage: + if (!userSettings.shortcutShowContactsPage.initialized) { + userSettings.shortcutShowContactsPage = + Shortcut(type.defaultShortcutActivator, true); + } + break; + case HomePageAction.showFilesPage: + if (!userSettings.shortcutShowFilesPage.initialized) { + userSettings.shortcutShowFilesPage = + Shortcut(type.defaultShortcutActivator, true); + } + break; + case HomePageAction.showSettingsDialog: + if (!userSettings.shortcutShowSettingsDialog.initialized) { + userSettings.shortcutShowSettingsDialog = + Shortcut(type.defaultShortcutActivator, true); + } + break; + case HomePageAction.showAboutDialog: + if (!userSettings.shortcutShowAboutDialog.initialized) { + userSettings.shortcutShowAboutDialog = + Shortcut(type.defaultShortcutActivator, true); + } + break; + } + } + return userSettings + // Set default values if the user hasn't set them. + ..actionOnClose ??= SettingActionOnClose.minimizeToTray + ..newMessageNotification ??= true + ..launchOnStartup ??= await autostartManager.isEnabled() + ..checkForUpdatesAutomatically ??= true; + } + + Future> _getIdToConversationSettings( + ConversationSettingRepository conversationSettingRepository) async { + final conversationSettingsTableData = + await conversationSettingRepository.selectAll(); + final (idToSettings, exception) = + ConversationSettings.fromTableData(conversationSettingsTableData); + if (exception != null) { + if (kReleaseMode) { + logger.warn( + 'Failed to read conversation settings: ${exception.toString()}'); + } else { + throw exception; + } + } + return idToSettings; + } + + Future loadData(ConversationService conversationService, + UserService userService, MessageRepository messageRepository) async { + await Future.wait([ + loadContacts(userService), + loadRelationshipGroups(userService), + loadConversations(conversationService, messageRepository) + ]); + } + + Future loadRelationshipGroups(UserService userService) async { + await TAsyncData.fromFuture( + () => userService.queryRelationshipGroups(), + ).forEach((data) { + if (_isLoggedIn) { + _relationshipGroupsDataController!.state = data; + } + }); + } + + Future loadContacts(UserService userService) async { + await TAsyncData.fromFuture( + () => userService.queryContacts(), + ).forEach((data) { + if (_isLoggedIn) { + _contactsDataController!.state = data; + } + }); + } + + Future loadConversations(ConversationService conversationService, + MessageRepository messageRepository) async { + final user = _loggedInUserController!.state!; + final userId = user.userId; + final dataStream = TAsyncData.fromFuture( + () => conversationService.queryConversations(), + ); + // TODO: if (fakeDataEnabled) { + if (true) { + await messageRepository.delete(idEnd: Int64.ZERO); + } + await for (final data in dataStream) { + if (_isLoggedIn) { + final conversations = data.value; + if (conversations != null && conversations.isNotEmpty) { + final messages = []; + for (final conversation in conversations) { + for (final message in conversation.messages) { + messages.add(message); + } + } + if (messages.isNotEmpty) { + await messageRepository.upsertMessages(messages: messages); + } + } + _conversationsDataController!.setData(data); + } + } + + final random = Random(); + Timer.periodic( + const Duration(seconds: 3), + (timer) => _generateFakeMessage(messageRepository, timer, random), + ); + } + + void _generateFakeMessage( + MessageRepository messageRepository, Timer timer, Random random) { + if (!_isLoggedIn) { + timer.cancel(); + return; + } + final conversations = _conversationsDataController!.getConversations(); + final conversationCount = conversations.length; + if (conversationCount == 0) { + return; + } + + final fakeMessage = StringBuffer(); + final maxLength = 1 + random.nextInt(200); + for (var i = 0; i < maxLength; i++) { + fakeMessage.writeCharCode(32 + random.nextInt(10000)); + } + final message = 'fake message: $fakeMessage'; + + Conversation conversation; + while (true) { + conversation = conversations[random.nextInt(conversationCount)]; + final now = DateTime.now(); + if (conversation is UserConversation) { + final contactId = conversation.contact.userId; + if (contactId != _loggedInUserId) { + final chatMessage = ChatMessage.parse( + text: message, + // Use a negative id so that we can identify these fake message. + messageId: -RandomUtils.nextUniquePositiveInt64(), + senderId: contactId, + recipientId: _loggedInUserId, + sentByMe: false, + isGroupMessage: false, + timestamp: now, + status: MessageDeliveryStatus.delivered); + _onMessageReceived( + messageRepository, chatMessage, conversation, conversations); + return; + } + } else if (conversation is GroupConversation && + conversation.contact.members.length > 1) { + final senderId = conversation.contact.members + .firstWhere((member) => member.userId != _loggedInUserId) + .userId; + final chatMessage = ChatMessage.parse( + text: message, + messageId: -RandomUtils.nextUniquePositiveInt64(), + senderId: senderId, + groupId: conversation.contact.groupId, + sentByMe: false, + isGroupMessage: true, + timestamp: now, + status: MessageDeliveryStatus.delivered); + _onMessageReceived( + messageRepository, chatMessage, conversation, conversations); + break; + } + if (conversationCount == 1) { + return; + } + } + } + + void _onMessageReceived( + MessageRepository messageRepository, + ChatMessage message, + Conversation conversationForIncomingMessage, + List conversations) { + final user = _loggedInUserController!.state!; + unawaited(messageRepository.upsertMessage(message: message)); + _conversationsDataController! + .addMessage(conversationForIncomingMessage, message); + + final selectedConversation = _selectedConversationController!.value; + if (selectedConversation?.id == conversationForIncomingMessage.id) { + _selectedConversationController!.notifyListeners(); + } else { + conversationForIncomingMessage.unreadMessageCount++; + } + if ((_userSettingsController!.state?.newMessageNotification ?? false) && + user.presence != UserPresence.doNotDisturb) { + WindowUtils.isVisible().then((isVisible) { + if (isVisible) { + return; + } + final name = conversationForIncomingMessage.contact.name; + final body = switch (message.type) { + MessageType.text => message.text!, + MessageType.file => + '[${readGlobalState(appLocalizationsViewModel).file}]', + MessageType.image => + '[${readGlobalState(appLocalizationsViewModel).image}]', + MessageType.video => + '[${readGlobalState(appLocalizationsViewModel).video}]', + MessageType.audio => + '[${readGlobalState(appLocalizationsViewModel).audio}]', + MessageType.youtube => + '[${readGlobalState(appLocalizationsViewModel).youtube}]', + }; + NotificationUtils.showNotification(name, body); + }); + } + } + + void onLoggedOut() { + _isLoggedIn = false; + _selectedConversationController = null; + _loggedInUserController = null; + _userSettingsController = null; + _idToConversationSettingsController = null; + _contactsDataController = null; + _relationshipGroupsDataController = null; + _conversationsDataController = null; + _selectedConversationController = null; + } +} + +late UserSessionManager userSessionManager; diff --git a/turms-chat-demo-flutter/lib/domain/user/models/contact.dart b/turms-chat-demo-flutter/lib/domain/user/models/contact.dart new file mode 100644 index 0000000000..d4746f1f7b --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/contact.dart @@ -0,0 +1,61 @@ +import 'dart:typed_data'; + +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../ui/desktop/components/index.dart'; +import '../../../ui/l10n/app_localizations.dart'; +import 'group_member.dart'; +import 'user.dart'; + +part 'group_contact.dart'; + +part 'system_contact.dart'; + +part 'user_contact.dart'; + +part 'private_contact.dart'; + +sealed class Contact { + Contact({ + required this.recordId, + required this.id, + required this.name, + this.intro = '', + this.imageUrl, + this.imageBytes, + this.icon, + }); + + final String recordId; + + /// user ID, group ID, or system contact type ID + final Int64 id; + final String name; + final String intro; + final String? imageUrl; + final Uint8List? imageBytes; + final IconData? icon; + + ImageProvider? _cachedImage; + + ImageProvider? get image { + if (_cachedImage != null) { + return _cachedImage; + } + if (imageBytes case final imageBytes?) { + return _cachedImage = MemoryImage(imageBytes); + } + if (imageUrl case final imageUrl?) { + return _cachedImage = NetworkImage(imageUrl); + } + return null; + } + + bool get isFileTransfer { + final contact = this; + return contact is SystemContact && + contact.type == SystemContactType.fileTransfer; + } +} diff --git a/turms-chat-demo-flutter/lib/domain/user/models/friend_request.dart b/turms-chat-demo-flutter/lib/domain/user/models/friend_request.dart new file mode 100644 index 0000000000..991f716645 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/friend_request.dart @@ -0,0 +1,19 @@ +import '../../common/models/new_relationship_request.dart'; +import '../../common/models/request_status.dart'; + +class FriendRequest extends NewRelationshipRequest { + FriendRequest( + {required super.id, + required super.status, + required super.sender, + required super.creationDate, + required super.message}); + + FriendRequest copyWith({required RequestStatus status}) => FriendRequest( + id: id, + status: status, + sender: sender, + creationDate: creationDate, + message: message, + ); +} diff --git a/turms-chat-demo-flutter/lib/domain/user/models/group_contact.dart b/turms-chat-demo-flutter/lib/domain/user/models/group_contact.dart new file mode 100644 index 0000000000..fce646ee92 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/group_contact.dart @@ -0,0 +1,15 @@ +part of 'contact.dart'; + +class GroupContact extends Contact { + GroupContact( + {required this.groupId, + required this.members, + required super.name, + super.intro, + super.imageUrl, + super.imageBytes}) + : super(recordId: 'group:$groupId', id: groupId); + + final Int64 groupId; + final List members; +} diff --git a/turms-chat-demo-flutter/lib/domain/user/models/group_member.dart b/turms-chat-demo-flutter/lib/domain/user/models/group_member.dart new file mode 100644 index 0000000000..7717ab5c5a --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/group_member.dart @@ -0,0 +1,36 @@ +import '../../../ui/desktop/components/t_avatar/t_avatar.dart'; +import 'user.dart'; + +class GroupMember extends User { + GroupMember({ + required super.userId, + required super.name, + super.intro, + super.imageUrl, + super.imageBytes, + required this.isAdmin, + super.presence, + }); + + factory GroupMember.fromUser(User user, {bool isAdmin = false}) => + GroupMember( + userId: user.userId, + name: user.name, + intro: user.intro, + imageUrl: user.imageUrl, + imageBytes: user.imageBytes, + isAdmin: isAdmin, + presence: user.presence); + + final bool isAdmin; + + @override + GroupMember? copyWith({UserPresence? presence}) => GroupMember( + userId: userId, + name: name, + intro: intro, + imageUrl: imageUrl, + imageBytes: imageBytes, + isAdmin: isAdmin, + presence: presence ?? this.presence); +} diff --git a/turms-chat-demo-flutter/lib/domain/user/models/index.dart b/turms-chat-demo-flutter/lib/domain/user/models/index.dart new file mode 100644 index 0000000000..4f37503a0f --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/index.dart @@ -0,0 +1,6 @@ +export 'contact.dart'; +export 'friend_request.dart'; +export 'relationship_group.dart'; +export 'user.dart'; +export 'user_setting.dart'; +export 'user_settings.dart'; diff --git a/turms-chat-demo-flutter/lib/domain/user/models/private_contact.dart b/turms-chat-demo-flutter/lib/domain/user/models/private_contact.dart new file mode 100644 index 0000000000..c56ad1b658 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/private_contact.dart @@ -0,0 +1,13 @@ +part of 'contact.dart'; + +sealed class PrivateContact extends Contact { + PrivateContact({ + required super.recordId, + required super.id, + required super.name, + super.intro = '', + super.imageUrl, + super.imageBytes, + super.icon, + }); +} diff --git a/turms-chat-demo-flutter/lib/domain/user/models/relationship_group.dart b/turms-chat-demo-flutter/lib/domain/user/models/relationship_group.dart new file mode 100644 index 0000000000..c94356930d --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/relationship_group.dart @@ -0,0 +1,46 @@ +import 'package:fixnum/fixnum.dart'; + +import 'contact.dart'; + +const groupRelationshipGroupId = Int64.MIN_VALUE; + +class RelationshipGroup { + factory RelationshipGroup.forUser({ + required Int64 id, + required String name, + required bool isBlocked, + required List contacts, + }) => + RelationshipGroup( + id: id, + name: name, + isBlocked: isBlocked, + contacts: contacts, + ); + + factory RelationshipGroup.forGroup({ + required String name, + required bool isBlocked, + required List contacts, + }) => + RelationshipGroup( + id: groupRelationshipGroupId, + name: name, + isBlocked: isBlocked, + contacts: contacts, + ); + + const RelationshipGroup({ + required this.id, + required this.name, + required this.isBlocked, + required this.contacts, + }); + + final Int64 id; + final String name; + final bool isBlocked; + final List contacts; + + bool get isGroup => id == groupRelationshipGroupId; +} diff --git a/turms-chat-demo-flutter/lib/domain/user/models/setting_action_on_close.dart b/turms-chat-demo-flutter/lib/domain/user/models/setting_action_on_close.dart new file mode 100644 index 0000000000..3f2e7ecedc --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/setting_action_on_close.dart @@ -0,0 +1,13 @@ +import 'package:collection/collection.dart'; + +enum SettingActionOnClose { + minimizeToTray(0), + exit(1); + + const SettingActionOnClose(this.id); + + final int id; + + static SettingActionOnClose? fromId(int id) => + values.firstWhereOrNull((e) => e.id == id); +} diff --git a/turms-chat-demo-flutter/lib/domain/user/models/setting_locale.dart b/turms-chat-demo-flutter/lib/domain/user/models/setting_locale.dart new file mode 100644 index 0000000000..8e2f0cc3d1 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/setting_locale.dart @@ -0,0 +1,10 @@ +enum SettingLocale { + system('system'), + en('en'), + ja('ja'), + zhCn('zh'); + + const SettingLocale(this.name); + + final String name; +} diff --git a/turms-chat-demo-flutter/lib/domain/user/models/system_contact.dart b/turms-chat-demo-flutter/lib/domain/user/models/system_contact.dart new file mode 100644 index 0000000000..cecf9d3aba --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/system_contact.dart @@ -0,0 +1,34 @@ +part of 'contact.dart'; + +class SystemContact extends PrivateContact { + factory SystemContact.forFileTransfer(AppLocalizations appLocalizations) => + SystemContact( + type: SystemContactType.fileTransfer, + name: appLocalizations.fileTransfer, + icon: Symbols.drive_file_move_rounded); + + factory SystemContact.forRequestNotification( + AppLocalizations appLocalizations) => + SystemContact( + type: SystemContactType.requestNotification, + name: appLocalizations.requestNotification, + icon: Symbols.person_add_rounded); + + SystemContact( + {required this.type, + required super.name, + super.intro, + super.imageUrl, + super.imageBytes, + super.icon}) + : super(recordId: 'system:$type', id: Int64(type.id)); + + final SystemContactType type; +} + +enum SystemContactType { + fileTransfer, + requestNotification; + + int get id => index; +} diff --git a/turms-chat-demo-flutter/lib/domain/user/models/user.dart b/turms-chat-demo-flutter/lib/domain/user/models/user.dart new file mode 100644 index 0000000000..c70b7cfea3 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/user.dart @@ -0,0 +1,48 @@ +import 'dart:typed_data'; + +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/cupertino.dart'; + +import '../../../ui/desktop/components/index.dart'; + +class User { + User({ + required this.userId, + required this.name, + this.intro = '', + this.imageUrl, + this.imageBytes, + this.presence = UserPresence.none, + }); + + final Int64 userId; + final String name; + final String intro; + final String? imageUrl; + final Uint8List? imageBytes; + final UserPresence presence; + + ImageProvider? _cachedImage; + + ImageProvider? get image { + if (_cachedImage != null) { + return _cachedImage; + } + if (imageBytes case final imageBytes?) { + return _cachedImage = MemoryImage(imageBytes); + } + if (imageUrl case final imageUrl?) { + return _cachedImage = NetworkImage(imageUrl); + } + return null; + } + + User? copyWith({UserPresence? presence}) => User( + userId: userId, + name: name, + intro: intro, + imageUrl: imageUrl, + imageBytes: imageBytes, + presence: presence ?? this.presence, + ); +} diff --git a/turms-chat-demo-flutter/lib/domain/user/models/user_contact.dart b/turms-chat-demo-flutter/lib/domain/user/models/user_contact.dart new file mode 100644 index 0000000000..8b52561cc9 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/user_contact.dart @@ -0,0 +1,40 @@ +part of 'contact.dart'; + +class UserContact extends PrivateContact implements User { + UserContact({ + required this.userId, + required super.name, + super.intro, + super.imageUrl, + super.imageBytes, + this.relationshipGroupId, + this.presence = UserPresence.none, + }) : super(recordId: 'user:$userId', id: userId); + + factory UserContact.fromUser(User user, Int64 relationshipGroupId) => + UserContact( + userId: user.userId, + name: user.name, + intro: user.intro, + imageUrl: user.imageUrl, + imageBytes: user.imageBytes, + presence: user.presence, + relationshipGroupId: relationshipGroupId); + + @override + final Int64 userId; + final Int64? relationshipGroupId; + @override + final UserPresence presence; + + @override + UserContact? copyWith({UserPresence? presence}) => UserContact( + userId: userId, + name: name, + intro: intro, + imageUrl: imageUrl, + imageBytes: imageBytes, + relationshipGroupId: relationshipGroupId, + presence: presence ?? this.presence, + ); +} diff --git a/turms-chat-demo-flutter/lib/domain/user/models/user_setting.dart b/turms-chat-demo-flutter/lib/domain/user/models/user_setting.dart new file mode 100644 index 0000000000..7cc2b5ecd5 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/user_setting.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../infra/keyboard/shortcut_extensions.dart'; +import '../../../infra/keyboard/shortcut_utils.dart'; +import '../../../ui/l10n/locale_utils.dart'; +import 'setting_action_on_close.dart'; + +/// [D] for the dart value type; +/// [S] for the SQL value type. +enum UserSetting { + // TODO: update order + actionOnClose(0), + checkForUpdatesAutomatically(1), + launchOnStartup(2), + locale(3), + newMessageNotification(4), + shortcutShowChatPage(5), + shortcutShowContactsPage(6), + shortcutShowFilesPage(7), + shortcutShowSettingsDialog(8), + shortcutShowAboutDialog(9), + theme(10), + messageRecentSearch, String>(11); + + const UserSetting(this.id); + + final int id; + + static UserSetting? fromId(int id) => _idToSetting[id]; + + S? convertValueToSql(D value) => switch (this) { + UserSetting.actionOnClose => (value as SettingActionOnClose).id, + UserSetting.checkForUpdatesAutomatically => (value as bool).toInt(), + UserSetting.launchOnStartup => + throw UnsupportedError('Should not be called'), + UserSetting.locale => (value as Locale).toLanguageTag(), + UserSetting.newMessageNotification => (value as bool).toInt(), + UserSetting.shortcutShowChatPage || + UserSetting.shortcutShowContactsPage || + UserSetting.shortcutShowFilesPage || + UserSetting.shortcutShowSettingsDialog || + UserSetting.shortcutShowAboutDialog => + value == null ? null : (value as ShortcutActivator).toSqlBlob(), + UserSetting.theme => switch (value as ThemeMode) { + ThemeMode.system => 0, + ThemeMode.light => 1, + ThemeMode.dark => 2, + }, + UserSetting.messageRecentSearch => jsonEncode(value as List) + } as S?; + + D? convertSqlToValue(S value) => switch (this) { + UserSetting.actionOnClose => SettingActionOnClose.fromId(value as int), + UserSetting.checkForUpdatesAutomatically => (value as int).toBool(), + UserSetting.launchOnStartup => + throw UnsupportedError('Should not be called'), + UserSetting.locale => LocaleUtils.fromLanguageTag(value as String), + UserSetting.newMessageNotification => (value as int).toBool(), + UserSetting.shortcutShowChatPage || + UserSetting.shortcutShowContactsPage || + UserSetting.shortcutShowFilesPage || + UserSetting.shortcutShowSettingsDialog || + UserSetting.shortcutShowAboutDialog => + ShortcutUtils.fromSqlBlob(value as Uint8List), + UserSetting.theme => switch (value as int) { + 0 => ThemeMode.system, + 1 => ThemeMode.light, + 2 => ThemeMode.dark, + _ => throw UnsupportedError('Unknown theme mode: $value'), + }, + UserSetting.messageRecentSearch => + jsonDecode(value as String) as List + } as D?; +} + +final _idToSetting = { + for (final record in UserSetting.values) record.id: record, +}; diff --git a/turms-chat-demo-flutter/lib/domain/user/models/user_settings.dart b/turms-chat-demo-flutter/lib/domain/user/models/user_settings.dart new file mode 100644 index 0000000000..f07c8f73ec --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/models/user_settings.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; + +import '../../../infra/exception/stackful_exception.dart'; +import '../../../infra/shortcut/shortcut.dart'; +import '../../../infra/sqlite/user_database.dart'; +import 'setting_action_on_close.dart'; +import 'user_setting.dart'; + +class UserSettings { + const UserSettings(this._settingToValue); + + static (UserSettings, StackfulException?) fromTableData( + List records) { + // don't use const as the map should be mutable. + // ignore: prefer_const_constructors + final settings = UserSettings({}); + StackfulException? exception; + for (final record in records) { + final setting = UserSetting.fromId(record.id); + final recordValue = record.value; + if (setting == null) { + continue; + } + try { + _setSetting(settings, setting, recordValue.rawSqlValue); + } on Exception catch (e, s) { + exception ??= StackfulException( + cause: Exception('Failed to set the user settings'), + stackTrace: s, + suppressed: []); + exception.addSuppressed(StackfulException( + cause: Exception( + 'Failed to set the user setting "${setting.name}" with the value "$recordValue"'), + stackTrace: s, + suppressed: [e])); + } + } + return (settings, exception); + } + + static void _setSetting(UserSettings settings, + UserSetting setting, Object sqlValue) { + if (setting == UserSetting.launchOnStartup) { + return; + } + final value = setting.convertSqlToValue(sqlValue); + switch (setting) { + case UserSetting.actionOnClose: + settings.actionOnClose = value as SettingActionOnClose; + break; + case UserSetting.checkForUpdatesAutomatically: + settings.checkForUpdatesAutomatically = value as bool; + break; + case UserSetting.launchOnStartup: + // do nothing. + // The setting should be detected according to whether autostart is enabled in system settings, + // so it should not follow the stored setting. + break; + case UserSetting.locale: + settings.locale = value as Locale; + break; + case UserSetting.newMessageNotification: + settings.newMessageNotification = value as bool; + break; + case UserSetting.shortcutShowChatPage: + settings.shortcutShowChatPage = + value == null ? null : Shortcut(value as ShortcutActivator, true); + break; + case UserSetting.shortcutShowContactsPage: + settings.shortcutShowContactsPage = + value == null ? null : Shortcut(value as ShortcutActivator, true); + break; + case UserSetting.shortcutShowFilesPage: + settings.shortcutShowFilesPage = + value == null ? null : Shortcut(value as ShortcutActivator, true); + break; + case UserSetting.shortcutShowSettingsDialog: + settings.shortcutShowSettingsDialog = + value == null ? null : Shortcut(value as ShortcutActivator, true); + break; + case UserSetting.shortcutShowAboutDialog: + settings.shortcutShowAboutDialog = + value == null ? null : Shortcut(value as ShortcutActivator, true); + break; + case UserSetting.theme: + settings.theme = value as ThemeMode; + break; + case UserSetting.messageRecentSearch: + settings.messageRecentSearch = value as List; + break; + } + } + + final Map, Object?> _settingToValue; + + SettingActionOnClose? get actionOnClose => + _settingToValue[UserSetting.actionOnClose] as SettingActionOnClose?; + + set actionOnClose(SettingActionOnClose? value) { + _settingToValue[UserSetting.actionOnClose] = value; + } + + bool? get checkForUpdatesAutomatically => + _settingToValue[UserSetting.checkForUpdatesAutomatically] as bool?; + + set checkForUpdatesAutomatically(bool? value) { + _settingToValue[UserSetting.checkForUpdatesAutomatically] = value; + } + + bool? get launchOnStartup => + _settingToValue[UserSetting.launchOnStartup] as bool?; + + set launchOnStartup(bool? value) { + _settingToValue[UserSetting.launchOnStartup] = value; + } + + Locale? get locale => _settingToValue[UserSetting.locale] as Locale?; + + set locale(Locale? value) => _settingToValue[UserSetting.locale] = value; + + bool? get newMessageNotification => + _settingToValue[UserSetting.newMessageNotification] as bool?; + + set newMessageNotification(bool? value) => + _settingToValue[UserSetting.newMessageNotification] = value; + + Shortcut get shortcutShowChatPage => + _settingToValue[UserSetting.shortcutShowChatPage] as Shortcut? ?? + Shortcut.unset; + + set shortcutShowChatPage(Shortcut? value) => + _settingToValue[UserSetting.shortcutShowChatPage] = value; + + Shortcut get shortcutShowContactsPage => + _settingToValue[UserSetting.shortcutShowContactsPage] as Shortcut? ?? + Shortcut.unset; + + set shortcutShowContactsPage(Shortcut? value) => + _settingToValue[UserSetting.shortcutShowContactsPage] = value; + + Shortcut get shortcutShowFilesPage => + _settingToValue[UserSetting.shortcutShowFilesPage] as Shortcut? ?? + Shortcut.unset; + + set shortcutShowFilesPage(Shortcut? value) => + _settingToValue[UserSetting.shortcutShowFilesPage] = value; + + Shortcut get shortcutShowSettingsDialog => + _settingToValue[UserSetting.shortcutShowSettingsDialog] as Shortcut? ?? + Shortcut.unset; + + set shortcutShowSettingsDialog(Shortcut? value) => + _settingToValue[UserSetting.shortcutShowSettingsDialog] = value; + + Shortcut get shortcutShowAboutDialog => + _settingToValue[UserSetting.shortcutShowAboutDialog] as Shortcut? ?? + Shortcut.unset; + + set shortcutShowAboutDialog(Shortcut? value) => + _settingToValue[UserSetting.shortcutShowAboutDialog] = value; + + ThemeMode? get theme => _settingToValue[UserSetting.theme] as ThemeMode?; + + set theme(ThemeMode? value) => _settingToValue[UserSetting.theme] = value; + + List? get messageRecentSearch => + _settingToValue[UserSetting.messageRecentSearch] as List?; + + set messageRecentSearch(List? value) => + _settingToValue[UserSetting.messageRecentSearch] = value; +} diff --git a/turms-chat-demo-flutter/lib/domain/user/repositories/user_login_info_repository.dart b/turms-chat-demo-flutter/lib/domain/user/repositories/user_login_info_repository.dart new file mode 100644 index 0000000000..75b2f883f9 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/repositories/user_login_info_repository.dart @@ -0,0 +1,33 @@ +import 'package:drift/drift.dart'; +import 'package:fixnum/fixnum.dart'; + +import '../../../infra/sqlite/app_database.dart'; + +class UserLoginInfoRepository { + Future> selectUserLoginInfos() { + final selectResult = appDatabase.select(appDatabase.userLoginInfoTable) + ..orderBy([ + (record) => OrderingTerm( + expression: record.lastModifiedDate, mode: OrderingMode.desc) + ]); + return selectResult.get(); + } + + /// TODO: encrypt password for security. + Future upsert(Int64 userId, String password) async { + final now = DateTime.now(); + await appDatabase.into(appDatabase.userLoginInfoTable).insert( + UserLoginInfoTableCompanion.insert( + userId: userId, + password: password, + createdDate: now, + lastModifiedDate: now), + onConflict: DoUpdate((old) => UserLoginInfoTableCompanion.custom( + password: Constant(password), lastModifiedDate: Constant(now)))); + } + + Future deleteAll() => + appDatabase.delete(appDatabase.userLoginInfoTable).go(); +} + +final userLoginInfoRepository = UserLoginInfoRepository(); diff --git a/turms-chat-demo-flutter/lib/domain/user/repositories/user_setting_repository.dart b/turms-chat-demo-flutter/lib/domain/user/repositories/user_setting_repository.dart new file mode 100644 index 0000000000..04cc77602b --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/repositories/user_setting_repository.dart @@ -0,0 +1,42 @@ +import 'package:drift/drift.dart'; +import 'package:fixnum/fixnum.dart'; + +import '../../../infra/sqlite/user_database.dart'; +import '../models/user_setting.dart'; + +class UserSettingRepository { + Int64? userId; + String? userPassword; + + Future upsert(Int64 userId, UserSetting setting, + dynamic settingValue) async { + final sqlValue = setting.convertValueToSql(settingValue) as Object; + final now = DateTime.now(); + final database = createUserDatabaseIfNotExists(userId); + await database.into(database.userSettingTable).insert( + UserSettingTableCompanion.insert( + id: setting.id, + value: DriftAny(sqlValue), + createdDate: now, + lastModifiedDate: now, + ), + onConflict: DoUpdate((old) => UserSettingTableCompanion.custom( + value: Constant(DriftAny(sqlValue)), + lastModifiedDate: Constant(now)))); + } + + Future delete( + Int64 userId, UserSetting setting) async { + final database = createUserDatabaseIfNotExists(userId); + final delete = database.delete(database.userSettingTable) + ..where((t) => t.id.equals(setting.id)); + return delete.go(); + } + + Future> selectAll(Int64 userId) { + final database = createUserDatabaseIfNotExists(userId); + return database.select(database.userSettingTable).get(); + } +} + +final userSettingRepository = UserSettingRepository(); diff --git a/turms-chat-demo-flutter/lib/domain/user/services/user_service.dart b/turms-chat-demo-flutter/lib/domain/user/services/user_service.dart new file mode 100644 index 0000000000..20c1e922aa --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/services/user_service.dart @@ -0,0 +1,90 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../infra/core/comparable_utils.dart'; +import '../../../ui/desktop/components/index.dart'; +import '../../../ui/desktop/pages/app.dart'; +import '../../../ui/l10n/app_localizations.dart'; +import '../../../ui/l10n/view_models/app_localizations_view_model.dart'; +import '../../common/fixtures/fixtures.dart'; +import '../fixtures/contacts.dart'; +import '../fixtures/relationship_groups.dart'; +import '../models/index.dart'; +import '../view_models/logged_in_user_info_view_model.dart'; + +class UserService { + const UserService(this._loggedInUser); + + final User _loggedInUser; + + Future> queryRelationshipGroups() async { + final appLocalizations = readGlobalState(appLocalizationsViewModel); + final locale = appLocalizations.localeName; + await Future.delayed(const Duration(seconds: 3)); + return ComparableUtils.sortByStrings( + locale, + Fixtures.instance.userRelationshipGroups, + (item) => item.name, + ) + + [ + Fixtures.instance + .getGroupRelationshipGroup(_loggedInUser, appLocalizations) + ]; + } + + Future> queryContacts() async { + final locale = readGlobalState(appLocalizationsViewModel).localeName; + await Future.delayed(const Duration(seconds: 3)); + final contacts = Fixtures.instance.getContacts(_loggedInUser); + return ComparableUtils.sortByStrings( + locale, contacts, (contact) => contact.name); + } + + List getSystemContacts(AppLocalizations appLocalizations) => [ + SystemContact.forRequestNotification(appLocalizations), + SystemContact.forFileTransfer(appLocalizations), + ]; + + Future acceptFriendRequest(Int64 id) async { + await Future.delayed(const Duration(seconds: 3)); + } + + static Future login(Int64 userId) async { + await Future.delayed(const Duration(seconds: 1)); + return User( + userId: userId, name: 'James Chen', presence: UserPresence.available); + } + + User queryUsers(Int64 senderId) => Fixtures.instance.userContacts + .firstWhere((element) => element.userId == senderId); + + Future> searchUserContacts( + Int64 userId, String searchText) async { + await Future.delayed(const Duration(seconds: 3)); + return [ + UserContact( + userId: userId, + name: 'a fake user name: $searchText' * 10, + intro: 'a fake user intro', + relationshipGroupId: Int64(-1), + ) + ]; + } + + Future sendFriendRequest(Int64 userId, String content) async { + await Future.delayed(const Duration(seconds: 3)); + } + + void updatePresence(UserPresence presence) { + final loggedInUser = readGlobalState(loggedInUserViewModel)!; + if (loggedInUser.presence == presence) { + return; + } + readGlobalState(loggedInUserViewModel.notifier).state = + loggedInUser.copyWith(presence: presence); + } +} + +final userServiceProvider = StateProvider( + (ref) => null, +); diff --git a/turms-chat-demo-flutter/lib/domain/user/tables/user_login_info_table.dart b/turms-chat-demo-flutter/lib/domain/user/tables/user_login_info_table.dart new file mode 100644 index 0000000000..78e3c7cc36 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/tables/user_login_info_table.dart @@ -0,0 +1,25 @@ +import 'package:drift/drift.dart'; + +import '../../../infra/sqlite/converter/int64_converter.dart'; + +class UserLoginInfoTable extends Table { + late final userId = int64().map(const Int64Converter())(); + + late final password = text()(); + + late final createdDate = dateTime()(); + + late final lastModifiedDate = dateTime()(); + + @override + String get tableName => 'user_login_info'; + + @override + Set get primaryKey => {userId}; + + @override + bool get withoutRowId => true; + + @override + bool get isStrict => true; +} diff --git a/turms-chat-demo-flutter/lib/domain/user/tables/user_setting_table.dart b/turms-chat-demo-flutter/lib/domain/user/tables/user_setting_table.dart new file mode 100644 index 0000000000..fe6f4c6863 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/tables/user_setting_table.dart @@ -0,0 +1,26 @@ +import 'package:drift/drift.dart'; + +/// The table should only be used for development purposes without the server. +/// The client should fetch user settings dynamically from the server in +/// production. +class UserSettingTable extends Table { + late final id = integer()(); + + late final value = sqliteAny()(); + + late final createdDate = dateTime()(); + + late final lastModifiedDate = dateTime()(); + + @override + String get tableName => 'user_setting'; + + @override + Set get primaryKey => {id}; + + @override + bool get withoutRowId => true; + + @override + bool get isStrict => true; +} diff --git a/turms-chat-demo-flutter/lib/domain/user/view_models/logged_in_user_info_view_model.dart b/turms-chat-demo-flutter/lib/domain/user/view_models/logged_in_user_info_view_model.dart new file mode 100644 index 0000000000..538f709fc7 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/view_models/logged_in_user_info_view_model.dart @@ -0,0 +1,5 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/user.dart'; + +final loggedInUserViewModel = StateProvider((ref) => null); diff --git a/turms-chat-demo-flutter/lib/domain/user/view_models/user_login_infos_view_model.dart b/turms-chat-demo-flutter/lib/domain/user/view_models/user_login_infos_view_model.dart new file mode 100644 index 0000000000..a2735ff8f3 --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/view_models/user_login_infos_view_model.dart @@ -0,0 +1,6 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../infra/sqlite/app_database.dart'; + +final userLoginInfosViewModel = + StateProvider>((ref) => []); diff --git a/turms-chat-demo-flutter/lib/domain/user/view_models/user_settings_view_model.dart b/turms-chat-demo-flutter/lib/domain/user/view_models/user_settings_view_model.dart new file mode 100644 index 0000000000..f0bf53068c --- /dev/null +++ b/turms-chat-demo-flutter/lib/domain/user/view_models/user_settings_view_model.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/user_settings.dart'; + +late Ref userSettingsViewModelRef; +final userSettingsViewModel = StateProvider((ref) { + userSettingsViewModelRef = ref; + return null; +}); diff --git a/turms-chat-demo-flutter/lib/infra/animation/animation_extensions.dart b/turms-chat-demo-flutter/lib/infra/animation/animation_extensions.dart new file mode 100644 index 0000000000..15b4591ce2 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/animation/animation_extensions.dart @@ -0,0 +1,19 @@ +import 'package:flutter/animation.dart'; + +extension AnimationStatusExtensions on AnimationStatus { + bool get isDismissed => switch (this) { + AnimationStatus.completed || + AnimationStatus.forward || + AnimationStatus.reverse => + false, + AnimationStatus.dismissed => true, + }; + + bool get isNotDismissed => switch (this) { + AnimationStatus.completed || + AnimationStatus.forward || + AnimationStatus.reverse => + true, + AnimationStatus.dismissed => false, + }; +} diff --git a/turms-chat-demo-flutter/lib/infra/animation/animation_utils.dart b/turms-chat-demo-flutter/lib/infra/animation/animation_utils.dart new file mode 100644 index 0000000000..09a48f5dc3 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/animation/animation_utils.dart @@ -0,0 +1,15 @@ +import 'package:flutter/animation.dart'; + +import 'dismissed_status_change_type.dart'; + +class AnimationUtils { + AnimationUtils._(); + + static DismissedStatusChangeType detectDismissedStatusChange( + AnimationStatus previousStatus, AnimationStatus status) => + switch ((previousStatus.isDismissed, status.isDismissed)) { + (false, true) => DismissedStatusChangeType.becomeDismissed, + (true, false) => DismissedStatusChangeType.becomeNotDismissed, + (true, true) || (false, false) => DismissedStatusChangeType.noChange + }; +} diff --git a/turms-chat-demo-flutter/lib/infra/animation/dismissed_status_change_type.dart b/turms-chat-demo-flutter/lib/infra/animation/dismissed_status_change_type.dart new file mode 100644 index 0000000000..fe4dc83810 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/animation/dismissed_status_change_type.dart @@ -0,0 +1 @@ +enum DismissedStatusChangeType { becomeDismissed, becomeNotDismissed, noChange } diff --git a/turms-chat-demo-flutter/lib/infra/app/app_config.dart b/turms-chat-demo-flutter/lib/infra/app/app_config.dart new file mode 100644 index 0000000000..46dd6b9ae8 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/app/app_config.dart @@ -0,0 +1,35 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../env/env_vars.dart'; + +class AppConfig { + AppConfig._(); + + static const title = EnvVars.windowTitle; + + static const defaultWindowSizeForLoginScreen = Size(480, 456); + + static const defaultWindowSizeForHomeScreen = Size( + 980, + // Get from: 64 (tile height) * 12 + 768); + static const minWindowSizeForHomeScreen = Size(700, 640); + + static late PackageInfo packageInfo; + + static late String appDir; + static late String tempDir; + + static Future load() async { + packageInfo = await PackageInfo.fromPlatform(); + final appDocDir = await getApplicationDocumentsDirectory(); + final appDocDirPath = await appDocDir.resolveSymbolicLinks(); + appDir = + '$appDocDirPath${Platform.pathSeparator}${AppConfig.packageInfo.packageName}'; + tempDir = await (await getTemporaryDirectory()).resolveSymbolicLinks(); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/app/app_utils.dart b/turms-chat-demo-flutter/lib/infra/app/app_utils.dart new file mode 100644 index 0000000000..14c27cfca0 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/app/app_utils.dart @@ -0,0 +1,12 @@ +import 'package:window_manager/window_manager.dart'; + +class AppUtils { + AppUtils._(); + + static Future close() async { + // Don't [SystemNavigator.pop()] as it won't work on desktop platforms. + // Don't use [WidgetsBinding.instance.exitApplication(AppExitType.required)] + // as it is buggy and will freeze the window for seconds, which is unacceptable. + await windowManager.close(); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/assets/assets.gen.dart b/turms-chat-demo-flutter/lib/infra/assets/assets.gen.dart new file mode 100644 index 0000000000..be10623aca --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/assets/assets.gen.dart @@ -0,0 +1,132 @@ +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: directives_ordering,unnecessary_import,implicit_dynamic_list_literal,deprecated_member_use + +import 'package:flutter/widgets.dart'; + +class $AssetsImagesGen { + const $AssetsImagesGen(); + + /// File path: assets/images/icon.png + AssetGenImage get iconPng => const AssetGenImage('assets/images/icon.png'); + + /// File path: assets/images/icon.svg + String get iconSvg => 'assets/images/icon.svg'; + + /// File path: assets/images/icon_1024.png + AssetGenImage get icon1024 => + const AssetGenImage('assets/images/icon_1024.png'); + + /// File path: assets/images/logo.svg + String get logo => 'assets/images/logo.svg'; + + /// File path: assets/images/powered_by_giphy_dark.png + AssetGenImage get poweredByGiphyDark => + const AssetGenImage('assets/images/powered_by_giphy_dark.png'); + + /// File path: assets/images/powered_by_giphy_light.png + AssetGenImage get poweredByGiphyLight => + const AssetGenImage('assets/images/powered_by_giphy_light.png'); + + /// List of all assets + List get values => [ + iconPng, + iconSvg, + icon1024, + logo, + poweredByGiphyDark, + poweredByGiphyLight + ]; +} + +class Assets { + Assets._(); + + static const $AssetsImagesGen images = $AssetsImagesGen(); +} + +class AssetGenImage { + const AssetGenImage( + this._assetName, { + this.size, + this.flavors = const {}, + }); + + final String _assetName; + + final Size? size; + final Set flavors; + + Image image({ + Key? key, + AssetBundle? bundle, + ImageFrameBuilder? frameBuilder, + ImageErrorWidgetBuilder? errorBuilder, + String? semanticLabel, + bool excludeFromSemantics = false, + double? scale, + double? width, + double? height, + Color? color, + Animation? opacity, + BlendMode? colorBlendMode, + BoxFit? fit, + AlignmentGeometry alignment = Alignment.center, + ImageRepeat repeat = ImageRepeat.noRepeat, + Rect? centerSlice, + bool matchTextDirection = false, + bool gaplessPlayback = true, + bool isAntiAlias = false, + String? package, + FilterQuality filterQuality = FilterQuality.low, + int? cacheWidth, + int? cacheHeight, + }) { + return Image.asset( + _assetName, + key: key, + bundle: bundle, + frameBuilder: frameBuilder, + errorBuilder: errorBuilder, + semanticLabel: semanticLabel, + excludeFromSemantics: excludeFromSemantics, + scale: scale, + width: width, + height: height, + color: color, + opacity: opacity, + colorBlendMode: colorBlendMode, + fit: fit, + alignment: alignment, + repeat: repeat, + centerSlice: centerSlice, + matchTextDirection: matchTextDirection, + gaplessPlayback: gaplessPlayback, + isAntiAlias: isAntiAlias, + package: package, + filterQuality: filterQuality, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + ); + } + + ImageProvider provider({ + AssetBundle? bundle, + String? package, + }) { + return AssetImage( + _assetName, + bundle: bundle, + package: package, + ); + } + + String get path => _assetName; + + String get keyName => _assetName; +} diff --git a/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager.dart b/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager.dart new file mode 100644 index 0000000000..8860a332cb --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import 'autostart_manager_linux.dart'; +import 'autostart_manager_macos.dart'; +import 'autostart_manager_unsupported.dart'; +import 'autostart_manager_windows.dart'; + +abstract class AutostartManager { + factory AutostartManager.create({ + required String appName, + required String appPath, + required List args, + }) { + if (kIsWeb) { + return AutostartManagerUnsupported(); + } + if (Platform.isWindows) { + return AutostartManagerWindows( + appName: appName, + appPath: appPath, + args: args, + ); + } else if (Platform.isMacOS) { + return AutostartManagerMacOS( + appName: appName, + appPath: appPath, + args: args, + ); + } else if (Platform.isLinux) { + return AutostartManagerLinux( + appName: appName, + appPath: appPath, + args: args, + ); + } + return AutostartManagerUnsupported(); + } + + AutostartManager({ + required this.appName, + required this.appPath, + this.args = const [], + }); + + final String appName; + final String appPath; + final List args; + + static bool isPlatformSupported() => + !kIsWeb && (Platform.isLinux || Platform.isMacOS || Platform.isWindows); + + Future isEnabled(); + + Future enable(); + + Future disable(); +} + +void initAutostartManager({ + required String appName, + required String appPath, + required List args, +}) { + autostartManager = AutostartManager.create( + appName: appName, + appPath: appPath, + args: args, + ); +} + +late AutostartManager autostartManager; diff --git a/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager_linux.dart b/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager_linux.dart new file mode 100644 index 0000000000..80a3ad1223 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager_linux.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +import 'autostart_manager.dart'; + +class AutostartManagerLinux extends AutostartManager { + AutostartManagerLinux({ + required String appName, + required String appPath, + List args = const [], + }) : super(appName: appName, appPath: appPath, args: args); + + File get _desktopFile => File( + '${Platform.environment['HOME']}/.config/autostart/$appName.desktop'); + + @override + Future isEnabled() async => _desktopFile.exists(); + + @override + Future enable() async { + final contents = ''' +[Desktop Entry] +Type=Application +Name=$appName +Comment=$appName startup script +Exec=${args.isEmpty ? appPath : '$appPath ${args.join(' ')}'} +StartupNotify=false +Terminal=false +'''; + if (!await _desktopFile.parent.exists()) { + await _desktopFile.parent.create(recursive: true); + } + await _desktopFile.writeAsString(contents); + } + + @override + Future disable() async { + if (await _desktopFile.exists()) { + await _desktopFile.delete(); + } + } +} diff --git a/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager_macos.dart b/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager_macos.dart new file mode 100644 index 0000000000..6902ae70ac --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager_macos.dart @@ -0,0 +1,55 @@ +import 'dart:io'; + +import 'autostart_manager.dart'; + +class AutostartManagerMacOS extends AutostartManager { + AutostartManagerMacOS({ + required String appName, + required String appPath, + List args = const [], + }) : super(appName: appName, appPath: appPath, args: args); + + File get _plistFile => File( + '${Platform.environment['HOME']}/Library/LaunchAgents/$appName.plist'); + + @override + Future isEnabled() async => _plistFile.exists(); + + @override + Future enable() async { + final contents = ''' + + + + + Label + $appName + ProgramArguments + + $appPath + ${args.map((e) => '$e').join("\n")} + + RunAtLoad + + ProcessType + Interactive + StandardErrorPath + /dev/null + StandardOutPath + /dev/null + + +'''; + if (!await _plistFile.parent.exists()) { + await _plistFile.parent.create(recursive: true); + } + await _plistFile.writeAsString(contents); + } + + @override + Future disable() async { + if (await _plistFile.exists()) { + await _plistFile.delete(); + } + } +} diff --git a/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager_unsupported.dart b/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager_unsupported.dart new file mode 100644 index 0000000000..0ab2af2ca7 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager_unsupported.dart @@ -0,0 +1,22 @@ +import 'autostart_manager.dart'; + +AutostartManager getWorker() => AutostartManagerUnsupported(); + +class AutostartManagerUnsupported extends AutostartManager { + AutostartManagerUnsupported() : super(appName: '', appPath: ''); + + @override + Future isEnabled() async { + throw UnsupportedError('isEnabled'); + } + + @override + Future enable() async { + throw UnsupportedError('enable'); + } + + @override + Future disable() async { + throw UnsupportedError('disable'); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager_windows.dart b/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager_windows.dart new file mode 100644 index 0000000000..dde32675f9 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/autostart/autostart_manager_windows.dart @@ -0,0 +1,43 @@ +import 'package:win32_registry/win32_registry.dart'; + +import 'autostart_manager.dart'; + +class AutostartManagerWindows extends AutostartManager { + AutostartManagerWindows({ + required String appName, + required String appPath, + List args = const [], + }) : super(appName: appName, appPath: appPath, args: args) { + _registryValue = args.isEmpty ? appPath : '$appPath ${args.join(' ')}'; + } + + late String _registryValue; + + RegistryKey get _regKey => Registry.openPath( + RegistryHive.currentUser, + path: r'Software\Microsoft\Windows\CurrentVersion\Run', + desiredAccessRights: AccessRights.allAccess, + ); + + @override + Future isEnabled() async { + final value = _regKey.getValueAsString(appName); + return value == _registryValue; + } + + @override + Future enable() async { + _regKey.createValue(RegistryValue( + appName, + RegistryValueType.string, + _registryValue, + )); + } + + @override + Future disable() async { + if (await isEnabled()) { + _regKey.deleteValue(appName); + } + } +} diff --git a/turms-chat-demo-flutter/lib/infra/built_in_types/built_in_type_helpers.dart b/turms-chat-demo-flutter/lib/infra/built_in_types/built_in_type_helpers.dart new file mode 100644 index 0000000000..376509d1c1 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/built_in_types/built_in_type_helpers.dart @@ -0,0 +1,171 @@ +import 'dart:collection'; +import 'dart:typed_data'; + +import 'package:fixnum/fixnum.dart'; + +extension IntExtensions on int { + bool toBool() => this != 0; + + Int64 toInt64() => Int64(this); + + Uint8List toVarInt64Bytes() { + final bytes = Uint8List(9); + var i = 0; + for (var b = this; b != 0; b >>= 7) { + bytes[i++] = b & 0x7F; + if (b > 0x7F) { + bytes[i - 1] |= 0x80; + } + } + return bytes.sublist(0, i); + } +} + +extension BoolExtensions on bool { + int toInt() => this ? 1 : 0; + + String toIntString() => this ? '1' : '0'; +} + +extension StringExtensions on String { + bool? toBool() => switch (this) { + '1' => true, + '0' => false, + _ => null, + }; + + (String, String) splitFirst(String separator) { + final separatorPosition = indexOf(separator); + if (separatorPosition == -1) { + return (this, ''); + } + return ( + substring(0, separatorPosition), + substring(separatorPosition + separator.length) + ); + } + + bool get isBlank => trim().isEmpty; + + bool get isNotBlank => !isBlank; + + String constCaseToCamelCase() { + final parts = toLowerCase().split('_'); + final camelCase = StringBuffer(parts[0]); + final partCount = parts.length; + for (var i = 1; i < partCount; i++) { + final part = parts[i]; + camelCase + ..write(part[0].toUpperCase()) + ..write(part.substring(1)); + } + return camelCase.toString(); + } +} + +extension EnumExtensionsIterable on Iterable { + T? firstOrNullByName(String? name) { + if (name == null) { + return null; + } + for (final value in this) { + if (value.name == name) { + return value; + } + } + return null; + } +} + +extension Iterables on Iterable { + Map> groupBy(K Function(E) keyFunction) => fold( + >{}, + (Map> map, E element) => + map..putIfAbsent(keyFunction(element), () => []).add(element)); + + LinkedHashMap> groupByAsLinkedHashMap( + K Function(E) keyFunction) => + fold( + LinkedHashMap>(), + (LinkedHashMap> map, E element) => + map..putIfAbsent(keyFunction(element), () => []).add(element)); +} + +extension ListExtensions on List { + void swap(int index1, int index2) { + if (index1 != index2) { + final tmp1 = this[index1]; + this[index1] = this[index2]; + this[index2] = tmp1; + } + } + + void replace(T oldElement, T newElement) { + final index = indexWhere((element) => element == oldElement); + if (index != -1) { + this[index] = newElement; + } + } +} + +extension Int32Extensions on Int32 { + BigInt toBigInt() => BigInt.parse(toString()); + + Uint8List toVarInt32Bytes() { + final bytes = Uint8List(5); + var i = 0; + for (var b = toInt(); b != 0; b >>= 7) { + bytes[i++] = b & 0x7F; + if (b > 0x7F) { + bytes[i - 1] |= 0x80; + } + } + return bytes.sublist(0, i); + } +} + +extension Int64Extensions on Int64 { + BigInt toBigInt() => BigInt.parse(toString()); + + Uint8List toVarInt64Bytes() { + final bytes = Uint8List(9); + var i = 0; + for (var b = toInt(); b != 0; b >>= 7) { + bytes[i++] = b & 0x7F; + if (b > 0x7F) { + bytes[i - 1] |= 0x80; + } + } + return bytes.sublist(0, i); + } +} + +extension BigIntExtensions on BigInt { + Int64 toInt64() => Int64.parseInt(toString()); +} + +(Int32, int) convertVarInt32BytesToInt32(Uint8List bytes) { + var result = 0; + final length = bytes.length; + for (var i = 0; i < length; i++) { + final b = bytes[i]; + result |= (b & 0x7F) << (7 * i); + if (b < 0x80) { + return (Int32(result), i + 1); + } + } + throw ArgumentError('Invalid varint encoding'); +} + +(Int64, int) convertVarInt64BytesToInt64(Uint8List bytes) { + var result = 0; + final length = bytes.length; + for (var i = 0; i < length; i++) { + final b = bytes[i]; + result |= (b & 0x7F) << (7 * i); + if (b < 0x80) { + return (Int64(result), i + 1); + } + } + throw ArgumentError('Invalid varint encoding'); +} diff --git a/turms-chat-demo-flutter/lib/infra/codec/base62_utils.dart b/turms-chat-demo-flutter/lib/infra/codec/base62_utils.dart new file mode 100644 index 0000000000..675dc949d7 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/codec/base62_utils.dart @@ -0,0 +1,136 @@ +import 'dart:math'; +import 'dart:typed_data'; + +class Base62Utils { + Base62Utils._(); + + static const _alphabet = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + static final _baseMap = _initBaseMap(); + + static Uint8List _initBaseMap() { + final baseMap = Uint8List(256); + baseMap.fillRange(0, baseMap.length, 255); + for (var i = 0; i < _alphabet.length; i++) { + final xc = _alphabet.codeUnitAt(i); + if (baseMap[xc] != 255) { + throw FormatException('${_alphabet[i]} is ambiguous'); + } + baseMap[xc] = i; + } + return baseMap; + } + + static String encode(List input) { + var zeroes = 0; + var length = 0; + var begin = 0; + final end = input.length; + while (begin != end && input[begin] == 0) { + begin++; + zeroes++; + } + + final size = + ((end - begin) * (log(256) / log(_alphabet.length)) + 1).toInt(); + final b58 = Uint8List(size); + + while (begin != end) { + var carry = input[begin]; + + var i = 0; + for (var it1 = size - 1; + (carry != 0 || i < length) && (it1 != -1); + it1--, i++) { + carry += 256 * b58[it1]; + b58[it1] = carry % _alphabet.length; + carry = carry ~/ _alphabet.length; + } + if (carry != 0) { + throw const FormatException('Non-zero carry'); + } + length = i; + begin++; + } + + var it2 = size - length; + while (it2 != size && b58[it2] == 0) { + it2++; + } + + final str = StringBuffer(); + final c = _alphabet[0]; + while (zeroes-- != 0) { + str.write(c); + } + for (; it2 < size; ++it2) { + str.write(_alphabet[b58[it2]]); + } + return str.toString(); + } + + static Uint8List decode(String input) { + if (input.isEmpty) { + return Uint8List(0); + } + var psz = 0; + + if (input[psz] == ' ') { + throw ArgumentError.value( + input, 'input', 'input cannot begin with a space.'); + } + + var zeroes = 0; + var length = 0; + while (input[psz] == _alphabet[0]) { + zeroes++; + psz++; + } + + final size = + (((input.length - psz) * (log(_alphabet.length) / log(256))) + 1) + .toInt(); + final b256 = Uint8List(size); + + while (psz < input.length && input[psz].isNotEmpty) { + var carry = _baseMap[input[psz].codeUnitAt(0)]; + + if (carry == 255) { + throw ArgumentError.value(input, 'input', + 'The character "${input[psz]}" at index $psz is invalid.'); + } + var i = 0; + for (var it3 = size - 1; + (carry != 0 || i < length) && (it3 != -1); + it3--, i++) { + carry += _alphabet.length * b256[it3]; + b256[it3] = carry % 256; + carry = carry ~/ 256; + } + if (carry != 0) { + throw const FormatException('Non-zero carry'); + } + length = i; + psz++; + } + + if (psz < input.length && input[psz] == ' ') { + throw ArgumentError.value( + input, 'input', 'input cannot end with a space.'); + } + + var it4 = size - length; + while (it4 != size && b256[it4] == 0) { + it4++; + } + final vch = Uint8List(zeroes + (size - it4)); + if (zeroes != 0) { + vch.fillRange(0, zeroes, 0x00); + } + var j = zeroes; + while (it4 != size) { + vch[j++] = b256[it4++]; + } + return vch; + } +} diff --git a/turms-chat-demo-flutter/lib/infra/codec/codec_utils.dart b/turms-chat-demo-flutter/lib/infra/codec/codec_utils.dart new file mode 100644 index 0000000000..5295fc8e64 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/codec/codec_utils.dart @@ -0,0 +1,42 @@ +import 'dart:typed_data'; + +import '../built_in_types/built_in_type_helpers.dart'; + +class CodecUtils { + CodecUtils._(); + + static Uint8List serialize2DArray(List twoDArray) { + var length = 0; + final lengthBytesList = []; + for (final list in twoDArray) { + final bytes = list.length.toVarInt64Bytes(); + lengthBytesList.add(bytes); + length += bytes.length; + length += list.length; + } + final flatList = Uint8List(length); + final count = twoDArray.length; + for (var i = 0; i < count; i++) { + final list = twoDArray[i]; + flatList + ..addAll(lengthBytesList[i]) + ..addAll(list); + } + return flatList; + } + + static List deserialize2DArray(Uint8List flatList) { + final result = []; + var offset = 0; + final bytesLength = flatList.length; + while (offset < bytesLength) { + final (length, readBytes) = convertVarInt64BytesToInt64(flatList); + offset += readBytes; + final lengthInt = length.toInt(); + final list = flatList.sublist(offset, offset + lengthInt); + result.add(list); + offset += lengthInt; + } + return result; + } +} diff --git a/turms-chat-demo-flutter/lib/infra/collection/list_holder.dart b/turms-chat-demo-flutter/lib/infra/collection/list_holder.dart new file mode 100644 index 0000000000..72edb099c2 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/collection/list_holder.dart @@ -0,0 +1,41 @@ +final class ListHolder { + ListHolder(this.list); + + final List list; + int? _hashCode; + + @override + String toString() => 'ListHolder(${list.join(', ')})'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! ListHolder) { + return false; + } + final list2 = other.list; + if (identical(list, list2)) { + return true; + } + final length = list.length; + if (length != list2.length) { + return false; + } + for (var i = 0; i < length; i++) { + if (list[i] != list2[i]) { + return false; + } + } + return true; + } + + @override + int get hashCode { + _hashCode ??= Object.hashAll(list); + return _hashCode!; + } +} + +typedef IntListHolder = ListHolder; diff --git a/turms-chat-demo-flutter/lib/infra/core/comparable_utils.dart b/turms-chat-demo-flutter/lib/infra/core/comparable_utils.dart new file mode 100644 index 0000000000..6c659aa856 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/core/comparable_utils.dart @@ -0,0 +1,46 @@ +import 'dart:typed_data'; + +import '../rust/api/icu.dart' as icu; + +class ComparableUtils { + ComparableUtils._(); + + static int compare(Comparable? a, Comparable? b) { + if (a == null) { + return b == null ? 0 : -1; + } + if (b == null) { + return 1; + } + return a.compareTo(b as T); + } + + static int compareBool(bool a, bool b) => a == b + ? 0 + : a + ? 1 + : -1; + + static int compareStrings(String locale, String s1, String s2) => + icu.compareStrings(locale: locale, s1: s1, s2: s2)!; + + static Uint16List compareStringList(String locale, List strings) => + icu.compareStringVec(locale: locale, strings: strings)!; + + static List sortByStrings( + String locale, List items, String stringExtractor(T item)) { + final strings = items.map(stringExtractor).toList(); + final indexes = icu.compareStringVec(locale: locale, strings: strings)!; + return List.generate(items.length, (i) => items[indexes[i]]); + } + + static Map sortByStringsAsMap( + String locale, List items, String stringExtractor(T item)) { + final strings = items.map(stringExtractor).toList(); + final indexes = icu.compareStringVec(locale: locale, strings: strings)!; + return Map.fromEntries(indexes.indexed.map((e) { + final (i, index) = e; + return MapEntry(items[index], i); + })); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/crypto/crypto_utils.dart b/turms-chat-demo-flutter/lib/infra/crypto/crypto_utils.dart new file mode 100644 index 0000000000..4731413531 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/crypto/crypto_utils.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; + +import '../codec/base62_utils.dart'; + +const _chunkSize = 4096; + +class CryptoUtils { + CryptoUtils._(); + + static String getSha256ByBytes(List bytes) { + final digest = sha256.convert(bytes); + return Base62Utils.encode(digest.bytes); + } + + static String getSha256ByString(String str) => + getSha256ByBytes(utf8.encode(str)); + + static Future getFileSha256(String path) async { + final reader = ChunkedStreamReader(File(path).openRead()); + final output = AccumulatorSink(); + final input = sha256.startChunkedConversion(output); + try { + while (true) { + final chunk = await reader.readChunk(_chunkSize); + if (chunk.isEmpty) { + // indicate end of file + break; + } + input.add(chunk); + } + } finally { + // We always cancel the ChunkedStreamReader, + // this ensures the underlying stream is cancelled. + await reader.cancel(); + } + + input.close(); + + return output.events.single; + } +} diff --git a/turms-chat-demo-flutter/lib/infra/data/t_async_data.dart b/turms-chat-demo-flutter/lib/infra/data/t_async_data.dart new file mode 100644 index 0000000000..bf5111b39a --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/data/t_async_data.dart @@ -0,0 +1,34 @@ +class TAsyncData { + const TAsyncData({ + this.value, + this.isLoading = false, + this.lastException, + }); + + final T? value; + final bool isLoading; + final Exception? lastException; + + bool get isInitialized => value != null; + + static Stream> fromFuture( + Future Function() provider) async* { + yield TAsyncData(isLoading: true); + try { + yield TAsyncData(value: await provider()); + } on Exception catch (e) { + yield TAsyncData(lastException: e); + } + } + + TAsyncData copyWith({ + T? value, + bool? isLoading, + Exception? lastException, + }) => + TAsyncData( + value: value ?? this.value, + isLoading: isLoading ?? this.isLoading, + lastException: lastException ?? this.lastException, + ); +} diff --git a/turms-chat-demo-flutter/lib/infra/env/env_vars.dart b/turms-chat-demo-flutter/lib/infra/env/env_vars.dart new file mode 100644 index 0000000000..6e2badee1e --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/env/env_vars.dart @@ -0,0 +1,21 @@ +class EnvVars { + EnvVars._(); + + static const String windowTitle = String.fromEnvironment('WINDOW_TITLE'); + static const bool databaseLogStatements = + bool.fromEnvironment('DATABASE_LOG_STATEMENTS'); + static const bool secureStorage = bool.fromEnvironment('SECURE_STORAGE'); + static const bool showFocusTracker = + bool.fromEnvironment('SHOW_FOCUS_TRACKER'); + static const int messageImageMaxDownloadableSizeBytes = + int.fromEnvironment('MESSAGE_IMAGE_MAX_DOWNLOADABLE_SIZE_BYTES'); + static final double messageImageMaxCachedSizeWidth = double.parse( + const String.fromEnvironment('MESSAGE_IMAGE_MAX_CACHED_SIZE_WIDTH')); + static final double messageImageMaxCachedSizeHeight = double.parse( + const String.fromEnvironment('MESSAGE_IMAGE_MAX_CACHED_SIZE_HEIGHT')); + static final double messageImageThumbnailSizeWidth = double.parse( + const String.fromEnvironment('MESSAGE_IMAGE_THUMBNAIL_SIZE_WIDTH')); + static final double messageImageThumbnailSizeHeight = double.parse( + const String.fromEnvironment('MESSAGE_IMAGE_THUMBNAIL_SIZE_HEIGHT')); + static const String giphyApiKey = String.fromEnvironment('GIPHY_API_KEY'); +} diff --git a/turms-chat-demo-flutter/lib/infra/exception/exception_extensions.dart b/turms-chat-demo-flutter/lib/infra/exception/exception_extensions.dart new file mode 100644 index 0000000000..99ed636a1a --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/exception/exception_extensions.dart @@ -0,0 +1,5 @@ +extension FormattedMessage on Exception { + String get message => toString().startsWith('Exception: ') + ? toString().substring(11) + : toString(); +} diff --git a/turms-chat-demo-flutter/lib/infra/exception/stackful_exception.dart b/turms-chat-demo-flutter/lib/infra/exception/stackful_exception.dart new file mode 100644 index 0000000000..2f0decfa5d --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/exception/stackful_exception.dart @@ -0,0 +1,18 @@ +class StackfulException implements Exception { + StackfulException( + {required this.cause, + required this.stackTrace, + required this.suppressed}); + + final Exception cause; + final StackTrace stackTrace; + final List suppressed; + + @override + String toString() => + 'StackfulException(cause: $cause, stackTrace: $stackTrace, suppressed: $suppressed)'; + + void addSuppressed(Exception e) { + suppressed.add(e); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/exception/user_visible_exception.dart b/turms-chat-demo-flutter/lib/infra/exception/user_visible_exception.dart new file mode 100644 index 0000000000..96c0b5625b --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/exception/user_visible_exception.dart @@ -0,0 +1,8 @@ +class UserVisibleException implements Exception { + UserVisibleException(this.cause, this.messageProvider); + + final T cause; + final String Function(T cause) messageProvider; + + String get message => messageProvider(cause); +} diff --git a/turms-chat-demo-flutter/lib/infra/github/github_asset.dart b/turms-chat-demo-flutter/lib/infra/github/github_asset.dart new file mode 100644 index 0000000000..d50f1cdde5 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/github/github_asset.dart @@ -0,0 +1,43 @@ +class GithubAsset { + GithubAsset({ + this.url, + this.id, + this.nodeId, + this.name, + this.label, + this.contentType, + this.state, + this.size, + this.downloadCount, + this.createdAt, + this.updatedAt, + this.browserDownloadUrl, + }); + + GithubAsset.fromJson(Map json) + : url = json['url'] as String?, + id = json['id'] as int?, + nodeId = json['node_id'] as String?, + name = json['name'] as String?, + label = json['label'] as String?, + contentType = json['content_type'] as String?, + state = json['state'] as String?, + size = json['size'] as int?, + downloadCount = json['download_count'] as int?, + createdAt = json['created_at'] as String?, + updatedAt = json['updated_at'] as String?, + browserDownloadUrl = json['browser_download_url'] as String?; + + final String? url; + final int? id; + final String? nodeId; + final String? name; + final String? label; + final String? contentType; + final String? state; + final int? size; + final int? downloadCount; + final String? createdAt; + final String? updatedAt; + final String? browserDownloadUrl; +} diff --git a/turms-chat-demo-flutter/lib/infra/github/github_client.dart b/turms-chat-demo-flutter/lib/infra/github/github_client.dart new file mode 100644 index 0000000000..74a517715e --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/github/github_client.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:pub_semver/pub_semver.dart'; + +import '../app/app_config.dart'; +import '../http/http_response_exception.dart'; +import '../http/http_utils.dart'; +import 'github_asset.dart'; +import 'versioned_asset.dart'; + +class GithubUtils { + GithubUtils._(); + + static Future? _pendingDownload; + + static Future fetchVersion() async { + // TODO: make configurable + const url = 'https://api.github.com/repos/turms-im/turms/releases'; + + final response = + await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10)); + + if (response.statusCode == 200) { + final releases = json.decode(response.body) as List; + if (releases.isEmpty) { + return null; + } + var latestRelease = releases[0] as Map; + var latestReleasePublishedAt = + DateTime.parse(latestRelease['published_at'] as String); + final releaseCount = releases.length; + for (var i = 1; i < releaseCount; i++) { + final release = releases[i] as Map; + final publishedAt = release['published_at'] as String; + final releasePublishedAt = DateTime.parse(publishedAt); + if (releasePublishedAt.isAfter(latestReleasePublishedAt)) { + latestRelease = release; + latestReleasePublishedAt = releasePublishedAt; + } + } + + final latestVersion = latestRelease['tag_name'] as String; + final latestAssets = latestRelease['assets'] as List; + for (final (asset as Map) in latestAssets) { + final name = asset['name'] as String; + if (name.contains('turms-chat-demo')) { + return VersionedAsset( + version: Version.parse(normalizeVersion(latestVersion)), + asset: GithubAsset.fromJson(asset)); + } + } + return null; + } + throw HttpResponseException(response); + } + + /// returns null if the current version is latest. + static Future downloadLatestApp() async { + var pending = _pendingDownload; + if (pending == null) { + _pendingDownload = pending = _downloadLatestApp0(); + } + return pending; + } + + static Future _downloadLatestApp0() async { + final versionedAsset = await GithubUtils.fetchVersion(); + if (versionedAsset == null) { + return null; + } + final currentVersion = Version.parse( + GithubUtils.normalizeVersion(AppConfig.packageInfo.version)); + if (versionedAsset.version < currentVersion) { + return null; + } + final asset = versionedAsset.asset; + final filePath = + '${AppConfig.appDir}${Platform.pathSeparator}app${Platform.pathSeparator}${asset.name!}'; + if (await File(filePath).exists()) { + return File(filePath); + } + final downloadFile = await HttpUtils.downloadFile( + taskId: filePath, + uri: Uri.parse(asset.browserDownloadUrl!), + filePath: filePath); + return downloadFile?.file; + } + + static String normalizeVersion(String version) { + if (version.startsWith('v')) { + final index = version.indexOf('-', 1); + if (index == -1) { + return version.substring(1); + } else { + return version.substring(1, index); + } + } + final index = version.indexOf('-', 1); + if (index == -1) { + return version; + } else { + return version.substring(0, index); + } + } +} diff --git a/turms-chat-demo-flutter/lib/infra/github/versioned_asset.dart b/turms-chat-demo-flutter/lib/infra/github/versioned_asset.dart new file mode 100644 index 0000000000..e9f8d09e63 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/github/versioned_asset.dart @@ -0,0 +1,10 @@ +import 'package:pub_semver/pub_semver.dart'; + +import 'github_asset.dart'; + +class VersionedAsset { + VersionedAsset({required this.version, required this.asset}); + + final Version version; + final GithubAsset asset; +} diff --git a/turms-chat-demo-flutter/lib/infra/http/downloaded_file.dart b/turms-chat-demo-flutter/lib/infra/http/downloaded_file.dart new file mode 100644 index 0000000000..429a8a3ccb --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/http/downloaded_file.dart @@ -0,0 +1,16 @@ +import 'dart:io'; +import 'dart:typed_data'; + +class DownloadedFile { + DownloadedFile({required this.file, List? bytes}) : _bytes = bytes; + + final File file; + final List? _bytes; + + Future get bytes { + if (_bytes case final bytes?) { + return Future.value(Uint8List.fromList(bytes)); + } + return file.readAsBytes(); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/http/file_too_large_exception.dart b/turms-chat-demo-flutter/lib/infra/http/file_too_large_exception.dart new file mode 100644 index 0000000000..1d3b5ce2b5 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/http/file_too_large_exception.dart @@ -0,0 +1,6 @@ +class FileTooLargeException implements Exception { + const FileTooLargeException([this.allowedBytes, this.actualBytes]); + + final int? allowedBytes; + final int? actualBytes; +} diff --git a/turms-chat-demo-flutter/lib/infra/http/http_extensions.dart b/turms-chat-demo-flutter/lib/infra/http/http_extensions.dart new file mode 100644 index 0000000000..e4860eb744 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/http/http_extensions.dart @@ -0,0 +1,15 @@ +import 'package:http/http.dart'; + +extension HttpResponseExtensions on Response { + String get description { + // TODO: map status code to localized text. + if (300 >= statusCode && statusCode > 200) { + return statusCode.toString(); + } + final contentType = headers['content-type'] ?? headers['Content-Type']; + if (contentType == 'text/plain') { + return '$statusCode: $body'; + } + return statusCode.toString(); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/http/http_response_exception.dart b/turms-chat-demo-flutter/lib/infra/http/http_response_exception.dart new file mode 100644 index 0000000000..7dde7ac817 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/http/http_response_exception.dart @@ -0,0 +1,7 @@ +import 'package:http/http.dart'; + +class HttpResponseException implements Exception { + HttpResponseException(this.response); + + final Response response; +} diff --git a/turms-chat-demo-flutter/lib/infra/http/http_utils.dart b/turms-chat-demo-flutter/lib/infra/http/http_utils.dart new file mode 100644 index 0000000000..e8a42955f6 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/http/http_utils.dart @@ -0,0 +1,107 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../io/file_utils.dart'; +import 'downloaded_file.dart'; +import 'file_too_large_exception.dart'; + +class HttpUtils { + HttpUtils._(); + + static final _pendingTaskIdToDownloadedFile = + >{}; + + static Future downloadFileIfNotExists( + {String? taskId, + String method = 'GET', + required Uri uri, + required String filePath, + int maxBytes = (1 << 32) - 1, + void Function(double progress)? onProgress}) async { + final file = File(filePath); + final exists = await file.exists(); + if (exists) { + return DownloadedFile(file: file); + } + return downloadFile( + taskId: taskId, + method: method, + uri: uri, + filePath: filePath, + maxBytes: maxBytes, + onProgress: onProgress); + } + + static Future downloadFile( + {String? taskId, + String method = 'GET', + required Uri uri, + required String filePath, + int maxBytes = (1 << 32) - 1, + void Function(double progress)? onProgress}) async { + if (taskId == null) { + return _downloadFile( + method: method, + uri: uri, + filePath: filePath, + maxBytes: maxBytes, + onProgress: onProgress, + ); + } + final downloadedFile = _pendingTaskIdToDownloadedFile[taskId]; + if (downloadedFile != null) { + return downloadedFile; + } + final downloadFile = _downloadFile( + method: method, + uri: uri, + filePath: filePath, + maxBytes: maxBytes, + onProgress: onProgress, + ); + _pendingTaskIdToDownloadedFile[taskId] = downloadFile; + return downloadFile.whenComplete( + () => unawaited(_pendingTaskIdToDownloadedFile.remove(taskId))); + } + + static Future _downloadFile( + {String method = 'GET', + required Uri uri, + required String filePath, + int maxBytes = (1 << 32) - 1, + void Function(double progress)? onProgress}) async { + final response = await http.Client().send(http.Request(method, uri)); + final contentLength = response.contentLength; + if (contentLength != null && contentLength > maxBytes) { + throw FileTooLargeException(maxBytes, contentLength); + } + var received = 0; + final bytes = []; + final completer = Completer(); + response.stream.listen( + (value) { + bytes.addAll(value); + received += value.length; + // The "contentLength" header is not always the real size, + // so we need to calculate size. + if (received > maxBytes) { + throw FileTooLargeException(maxBytes, received); + } + if (contentLength != null) { + onProgress?.call(received / contentLength); + } + }, + onError: completer.completeError, + onDone: () async { + if (bytes.isEmpty) { + completer.complete(); + return; + } + final file = await FileUtils.writeAsBytes(filePath, bytes); + completer.complete(DownloadedFile(file: file, bytes: bytes)); + }); + return completer.future; + } +} diff --git a/turms-chat-demo-flutter/lib/infra/http/resource_not_found_exception.dart b/turms-chat-demo-flutter/lib/infra/http/resource_not_found_exception.dart new file mode 100644 index 0000000000..eecdc176cf --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/http/resource_not_found_exception.dart @@ -0,0 +1,5 @@ +class ResourceNotFoundException implements Exception { + ResourceNotFoundException(this.uri); + + final String uri; +} diff --git a/turms-chat-demo-flutter/lib/infra/io/data_reader_file_adaptor.dart b/turms-chat-demo-flutter/lib/infra/io/data_reader_file_adaptor.dart new file mode 100644 index 0000000000..34c6e31314 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/io/data_reader_file_adaptor.dart @@ -0,0 +1,30 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:super_clipboard/super_clipboard.dart'; + +class DataReaderFileValueAdapter extends DataReaderFile { + DataReaderFileValueAdapter(this.file); + + final File file; + + @override + void close() { + // do nothing. + } + + @override + String? get fileName => file.path; + + @override + int? get fileSize => file.statSync().size; + + @override + Stream getStream() { + final stream = file.openRead(); + return stream.map(Uint8List.fromList); + } + + @override + Future readAll() => file.readAsBytes(); +} diff --git a/turms-chat-demo-flutter/lib/infra/io/file_utils.dart b/turms-chat-demo-flutter/lib/infra/io/file_utils.dart new file mode 100644 index 0000000000..94eca7e8a3 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/io/file_utils.dart @@ -0,0 +1,69 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../app/app_config.dart'; + +final pathSeparator = Platform.pathSeparator; + +class FileUtils { + FileUtils._(); + + static bool _isPickingFile = false; + + static Future getFileDownloadPath(String name, String? ext) async { + final downloadsDir = await getDownloadsDirectory(); + if (downloadsDir == null) { + return null; + } + ext = ext == null + ? '' + : ext.startsWith('.') + ? ext + : '.$ext'; + var path = + '${downloadsDir.path}$pathSeparator${AppConfig.packageInfo.packageName}$pathSeparator$name$ext'; + var num = 1; + while (await File(path).exists()) { + path = + '${downloadsDir.path}$pathSeparator${AppConfig.packageInfo.packageName}$pathSeparator$name (${num++})$ext'; + } + return path; + } + + static Future saveFile(String path, Uint8List content) async { + final file = File(path); + await file.writeAsBytes(content); + } + + static Future writeAsBytes(String filePath, List bytes) async { + final dir = Directory(dirname(filePath)); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + final file = File(filePath); + await file.writeAsBytes(bytes); + return file; + } + + static Future pickFile( + {List? allowedExtensions, bool withReadStream = false}) async { + if (_isPickingFile) { + return null; + } + _isPickingFile = true; + try { + return await FilePicker.platform.pickFiles( + type: allowedExtensions == null ? FileType.any : FileType.custom, + // TODO: translate filters + allowedExtensions: allowedExtensions, + withReadStream: withReadStream, + ); + } finally { + _isPickingFile = false; + } + } +} diff --git a/turms-chat-demo-flutter/lib/infra/io/global_keyboard_listener.dart b/turms-chat-demo-flutter/lib/infra/io/global_keyboard_listener.dart new file mode 100644 index 0000000000..db94d28ce3 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/io/global_keyboard_listener.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Used to listen to the keyboard without the interfere of the focus system. +class GlobalKeyboardListener extends StatefulWidget { + const GlobalKeyboardListener({ + super.key, + required this.onKeyEvent, + required this.child, + }); + + final KeyEventCallback onKeyEvent; + final Widget child; + + @override + State createState() => _GlobalKeyboardListenerState(); +} + +class _GlobalKeyboardListenerState extends State { + @override + void initState() { + super.initState(); + ServicesBinding.instance.keyboard.addHandler(widget.onKeyEvent); + } + + @override + void dispose() { + ServicesBinding.instance.keyboard.removeHandler(widget.onKeyEvent); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/turms-chat-demo-flutter/lib/infra/io/io_extensions.dart b/turms-chat-demo-flutter/lib/infra/io/io_extensions.dart new file mode 100644 index 0000000000..dccbe97d60 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/io/io_extensions.dart @@ -0,0 +1,13 @@ +import 'dart:async'; +import 'dart:typed_data'; + +extension IoStreamExtensions on Stream> { + Future toFuture() { + final completer = Completer(); + final list = []; + listen(list.addAll, onDone: () { + completer.complete(Uint8List.fromList(list)); + }); + return completer.future; + } +} diff --git a/turms-chat-demo-flutter/lib/infra/io/path_utils.dart b/turms-chat-demo-flutter/lib/infra/io/path_utils.dart new file mode 100644 index 0000000000..f3d493a932 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/io/path_utils.dart @@ -0,0 +1,22 @@ +import 'dart:io'; + +import '../app/app_config.dart'; + +/// Application folder structure: +/// - app +/// - user +/// - database +/// - files +class PathUtils { + PathUtils._(); + + static String joinAppPath(List paths) => + '${AppConfig.appDir}${Platform.pathSeparator}${paths.join(Platform.pathSeparator)}'; + + static String joinPathInAppScope(List paths) => + '${AppConfig.appDir}${Platform.pathSeparator}app${Platform.pathSeparator}${paths.join(Platform.pathSeparator)}'; + + // TODO + static String joinPathInUserScope(List paths) => + '${AppConfig.appDir}${Platform.pathSeparator}user${Platform.pathSeparator}${paths.join(Platform.pathSeparator)}'; +} diff --git a/turms-chat-demo-flutter/lib/infra/keyboard/key_event_extensions.dart b/turms-chat-demo-flutter/lib/infra/keyboard/key_event_extensions.dart new file mode 100644 index 0000000000..12a42e7912 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/keyboard/key_event_extensions.dart @@ -0,0 +1,19 @@ +import 'package:flutter/services.dart'; + +extension KeyEventExtensions on KeyEvent { + bool get isControlPressed => + logicalKey == LogicalKeyboardKey.controlLeft || + logicalKey == LogicalKeyboardKey.controlRight; + + bool get isShiftPressed => + logicalKey == LogicalKeyboardKey.shiftLeft || + logicalKey == LogicalKeyboardKey.shiftRight; + + bool get isAltPressed => + logicalKey == LogicalKeyboardKey.altLeft || + logicalKey == LogicalKeyboardKey.altRight; + + bool get isMetaPressed => + logicalKey == LogicalKeyboardKey.metaLeft || + logicalKey == LogicalKeyboardKey.metaRight; +} diff --git a/turms-chat-demo-flutter/lib/infra/keyboard/logical_keyboard_key_extensions.dart b/turms-chat-demo-flutter/lib/infra/keyboard/logical_keyboard_key_extensions.dart new file mode 100644 index 0000000000..53043b1f5a --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/keyboard/logical_keyboard_key_extensions.dart @@ -0,0 +1,23 @@ +import 'package:flutter/services.dart'; + +extension LogicalKeyboardKeyExtensions on LogicalKeyboardKey { + bool get isModifier => + this == LogicalKeyboardKey.control || + this == LogicalKeyboardKey.shift || + this == LogicalKeyboardKey.alt || + this == LogicalKeyboardKey.meta; + + LogicalKeyboardKey get normalizedKey => switch (this) { + LogicalKeyboardKey.numpad0 => LogicalKeyboardKey.digit0, + LogicalKeyboardKey.numpad1 => LogicalKeyboardKey.digit1, + LogicalKeyboardKey.numpad2 => LogicalKeyboardKey.digit2, + LogicalKeyboardKey.numpad3 => LogicalKeyboardKey.digit3, + LogicalKeyboardKey.numpad4 => LogicalKeyboardKey.digit4, + LogicalKeyboardKey.numpad5 => LogicalKeyboardKey.digit5, + LogicalKeyboardKey.numpad6 => LogicalKeyboardKey.digit6, + LogicalKeyboardKey.numpad7 => LogicalKeyboardKey.digit7, + LogicalKeyboardKey.numpad8 => LogicalKeyboardKey.digit8, + LogicalKeyboardKey.numpad9 => LogicalKeyboardKey.digit9, + _ => synonyms.firstOrNull ?? this + }; +} diff --git a/turms-chat-demo-flutter/lib/infra/keyboard/shortcut_extensions.dart b/turms-chat-demo-flutter/lib/infra/keyboard/shortcut_extensions.dart new file mode 100644 index 0000000000..f181678b51 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/keyboard/shortcut_extensions.dart @@ -0,0 +1,146 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'shortcut_utils.dart'; + +extension ShortcutActivatorExtensions on ShortcutActivator { + /// Don't call "keys" to avoid confusing with [KeySet.keys]. + List get keyList { + switch (this) { + case SingleActivator(): + final singleActivator = this as SingleActivator; + if (singleActivator.control) { + return [ + LogicalKeyboardKey.control, + singleActivator.trigger, + ]; + } else if (singleActivator.shift) { + return [ + LogicalKeyboardKey.shift, + singleActivator.trigger, + ]; + } else if (singleActivator.alt) { + return [ + LogicalKeyboardKey.alt, + singleActivator.trigger, + ]; + } else if (singleActivator.meta) { + return [ + LogicalKeyboardKey.meta, + singleActivator.trigger, + ]; + } else { + return [singleActivator.trigger]; + } + case LogicalKeySet(): + return (this as LogicalKeySet).keys.toList()..sortKeys(); + default: + return []; + } + } + + Uint8List toSqlBlob() => ShortcutUtils.toSqlBlob(this); + + String get description { + // LogicalKeySet + switch (this) { + case LogicalKeySet(): + final keys = (this as LogicalKeySet).keyList; + if (keys.isEmpty) { + return ''; + } + final buffer = StringBuffer(); + for (final key in keys) { + if (buffer.isNotEmpty) { + buffer.write(' + '); + } + if (key == LogicalKeyboardKey.control) { + buffer.write('Ctrl'); + } else { + buffer.write(key.keyLabel); + } + } + return buffer.toString(); + case SingleActivator(): + final buffer = StringBuffer(); + final activator = this as SingleActivator; + if (activator.alt) { + buffer.write('Alt'); + } + if (activator.control) { + if (buffer.isNotEmpty) { + buffer.write(' + '); + } + buffer.write('Ctrl'); + } + if (activator.meta) { + if (buffer.isNotEmpty) { + buffer.write(' + '); + } + buffer.write('Meta'); + } + if (activator.shift) { + if (buffer.isNotEmpty) { + buffer.write(' + '); + } + buffer.write('Shift'); + } + buffer.write(' + '); + buffer.write(activator.trigger.keyLabel); + return buffer.toString(); + default: + return ''; + } + } + + bool hasSameKeys(ShortcutActivator other) => + listEquals(keyList, other.keyList); +} + +extension SingleActivatorExtensions on SingleActivator { + LogicalKeySet toLogicalKeySet() { + final keys = {}; + if (alt) { + keys.add(LogicalKeyboardKey.alt); + } + if (control) { + keys.add(LogicalKeyboardKey.control); + } + if (meta) { + keys.add(LogicalKeyboardKey.meta); + } + if (shift) { + keys.add(LogicalKeyboardKey.shift); + } + keys.add(trigger); + return LogicalKeySet.fromSet(keys); + } +} + +extension ShortcutExtensionsIterable on List { + void sortKeys() { + sort(_compareKey); + } + + int _compareKey(LogicalKeyboardKey a, LogicalKeyboardKey b) { + if (a == LogicalKeyboardKey.control) { + return -1; + } else if (b == LogicalKeyboardKey.control) { + return 1; + } else if (a == LogicalKeyboardKey.shift) { + return -1; + } else if (b == LogicalKeyboardKey.shift) { + return 1; + } else if (a == LogicalKeyboardKey.alt) { + return -1; + } else if (b == LogicalKeyboardKey.alt) { + return 1; + } else if (a == LogicalKeyboardKey.meta) { + return -1; + } else if (b == LogicalKeyboardKey.meta) { + return 1; + } + return a.keyLabel.compareTo(b.keyLabel); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/keyboard/shortcut_utils.dart b/turms-chat-demo-flutter/lib/infra/keyboard/shortcut_utils.dart new file mode 100644 index 0000000000..1726c19166 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/keyboard/shortcut_utils.dart @@ -0,0 +1,50 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +class ShortcutUtils { + ShortcutUtils._(); + + static Uint8List toSqlBlob(ShortcutActivator activator) { + final finalKeys = []; + switch (activator) { + case LogicalKeySet(): + final keys = activator.keys; + if (keys.isEmpty) { + return Uint8List.fromList([]); + } + for (final key in keys) { + finalKeys.add(key.keyId); + } + break; + case SingleActivator(): + if (activator.alt) { + finalKeys.add(LogicalKeyboardKey.alt.keyId); + } + if (activator.control) { + finalKeys.add(LogicalKeyboardKey.control.keyId); + } + if (activator.meta) { + finalKeys.add(LogicalKeyboardKey.meta.keyId); + } + if (activator.shift) { + finalKeys.add(LogicalKeyboardKey.shift.keyId); + } + finalKeys.add(activator.trigger.keyId); + break; + default: + throw UnsupportedError('Unsupported ShortcutActivator: $activator'); + } + return Uint8List.fromList(finalKeys); + } + + static LogicalKeySet? fromSqlBlob(Uint8List keys) { + if (keys.isEmpty) { + return null; + } + final result = {}; + for (final key in keys) { + result.add(LogicalKeyboardKey(key)); + } + return LogicalKeySet.fromSet(result); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/logging/ansi_escape_codes.dart b/turms-chat-demo-flutter/lib/infra/logging/ansi_escape_codes.dart new file mode 100644 index 0000000000..46b06ea1a3 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/logging/ansi_escape_codes.dart @@ -0,0 +1,40 @@ +class AnsiColor { + AnsiColor.bg(this.bg) + : fg = null, + str = format(bg: bg); + + AnsiColor.fg(this.fg) + : bg = null, + str = format(fg: fg); + + static const ansiEsc = '\x1B['; + static const ansiDefault = '${ansiEsc}0m'; + + final int? fg; + final int? bg; + + final String str; + + static String format({int? fg, int? bg}) { + if (fg != null) { + return '${ansiEsc}38;5;${fg}m'; + } else if (bg != null) { + return '${ansiEsc}48;5;${bg}m'; + } else { + return ''; + } + } + + @override + String toString() { + if (fg != null) { + return '${ansiEsc}38;5;${fg}m'; + } else if (bg != null) { + return '${ansiEsc}48;5;${bg}m'; + } else { + return ''; + } + } + + static int grey(double level) => 232 + (level.clamp(0.0, 1.0) * 23).round(); +} diff --git a/turms-chat-demo-flutter/lib/infra/logging/log_appender.dart b/turms-chat-demo-flutter/lib/infra/logging/log_appender.dart new file mode 100644 index 0000000000..0bc6fa3eac --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/logging/log_appender.dart @@ -0,0 +1,9 @@ +import 'log_entry.dart'; + +abstract class LogAppender { + Future init() async {} + + Future destroy() async {} + + void append(LogEntry entry); +} diff --git a/turms-chat-demo-flutter/lib/infra/logging/log_appender_console.dart b/turms-chat-demo-flutter/lib/infra/logging/log_appender_console.dart new file mode 100644 index 0000000000..b514581a85 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/logging/log_appender_console.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'ansi_escape_codes.dart'; +import 'log_appender.dart'; +import 'log_entry.dart'; +import 'log_level.dart'; + +class _LogLevelInfo { + _LogLevelInfo(this.name, this.color); + + final String name; + final AnsiColor? color; +} + +class LogAppenderConsole extends LogAppender { + static final Map _levelToInfo = { + LogLevel.trace: _LogLevelInfo('TRACE', AnsiColor.fg(AnsiColor.grey(0.5))), + LogLevel.debug: _LogLevelInfo('DEBUG', null), + LogLevel.info: _LogLevelInfo(' INFO', AnsiColor.fg(12)), + LogLevel.warn: _LogLevelInfo(' WARN', AnsiColor.fg(208)), + LogLevel.error: _LogLevelInfo('ERROR', AnsiColor.fg(196)), + LogLevel.fatal: _LogLevelInfo('FATAL', AnsiColor.fg(199)), + }; + + @override + void append(LogEntry entry) { + final error = entry.error; + final stackTrace = entry.stackTrace; + final level = entry.level; + final levelInfo = _levelToInfo[level]!; + final levelName = levelInfo.name; + final color = levelInfo.color; + if (color != null) { + if (error == null) { + stdout.writeln( + '$color${entry.timestamp.toIso8601String()} $levelName : ${entry.message}${AnsiColor.ansiDefault}'); + } else { + stderr.writeln( + '$color${entry.timestamp.toIso8601String()} $levelName : ${entry.message}\n${error.toString()}${AnsiColor.ansiDefault}'); + if (stackTrace != null) { + stderr.writeln( + '$color${stackTrace.toString()}${AnsiColor.ansiDefault}'); + } + } + } else { + if (error == null) { + stdout.writeln( + '${entry.timestamp.toIso8601String()} $levelName : ${entry.message}'); + } else { + stderr.writeln( + '${entry.timestamp.toIso8601String()} $levelName : ${entry.message}\n${error.toString()}'); + if (stackTrace != null) { + stderr.writeln(stackTrace.toString()); + } + } + } + } +} diff --git a/turms-chat-demo-flutter/lib/infra/logging/log_appender_database.dart b/turms-chat-demo-flutter/lib/infra/logging/log_appender_database.dart new file mode 100644 index 0000000000..f5118d4047 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/logging/log_appender_database.dart @@ -0,0 +1,15 @@ +import 'package:fixnum/fixnum.dart'; + +import 'log_appender.dart'; +import 'log_entry.dart'; + +class LogAppenderDatabase extends LogAppender { + LogAppenderDatabase({ + required this.userId, + }); + + final Int64 userId; + + @override + void append(LogEntry entry) {} +} diff --git a/turms-chat-demo-flutter/lib/infra/logging/log_entry.dart b/turms-chat-demo-flutter/lib/infra/logging/log_entry.dart new file mode 100644 index 0000000000..bfd78bf052 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/logging/log_entry.dart @@ -0,0 +1,18 @@ +import 'log_level.dart'; + +class LogEntry { + LogEntry( + this.level, + this.message, { + DateTime? time, + this.error, + this.stackTrace, + }) : timestamp = time ?? DateTime.now(); + + final LogLevel level; + final dynamic message; + final Object? error; + final StackTrace? stackTrace; + + final DateTime timestamp; +} diff --git a/turms-chat-demo-flutter/lib/infra/logging/log_level.dart b/turms-chat-demo-flutter/lib/infra/logging/log_level.dart new file mode 100644 index 0000000000..33bdb641e9 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/logging/log_level.dart @@ -0,0 +1,17 @@ +enum LogLevel { + fatal(0), + error(1), + warn(2), + info(3), + debug(4), + trace(5); + + const LogLevel(this.value); + + final int value; + + static Map valueToLevel = + Map.fromEntries(LogLevel.values.map((e) => MapEntry(e.value, e))); + + static LogLevel? fromInt(int value) => valueToLevel[value]; +} diff --git a/turms-chat-demo-flutter/lib/infra/logging/logger.dart b/turms-chat-demo-flutter/lib/infra/logging/logger.dart new file mode 100644 index 0000000000..0e163d0b6b --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/logging/logger.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; + +import 'log_appender.dart'; +import 'log_appender_console.dart'; +import 'log_entry.dart'; +import 'log_level.dart'; + +class Logger { + Logger({required this.level, required this.appenders}); + + final LogLevel level; + final List appenders; + + void addAppender(LogAppender appender) { + appenders.add(appender); + } + + void removeAppender(LogAppender appender) { + appenders.remove(appender); + } + + void error(String message, [Object? error, StackTrace? stackTrace]) { + log(LogEntry(LogLevel.error, message, + error: error, stackTrace: stackTrace)); + } + + void warn(String message, [Object? error, StackTrace? stackTrace]) { + log(LogEntry( + LogLevel.warn, + message, + error: error, + stackTrace: stackTrace, + )); + } + + void info(String message) { + log(LogEntry(LogLevel.info, message)); + } + + void log(LogEntry entry) { + if (level.value < entry.level.value) { + return; + } + + for (final appender in appenders) { + appender.append(entry); + } + } +} + +final logger = Logger( + level: LogLevel.trace, + appenders: kDebugMode ? [LogAppenderConsole()] : [], +); diff --git a/turms-chat-demo-flutter/lib/infra/markdown/mention_syntax.dart b/turms-chat-demo-flutter/lib/infra/markdown/mention_syntax.dart new file mode 100644 index 0000000000..ebaf120444 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/markdown/mention_syntax.dart @@ -0,0 +1,14 @@ +import 'package:markdown/markdown.dart'; + +class MentionSyntax extends InlineSyntax { + MentionSyntax() : super(r'\B@(ALL|\d{1,19})\b'); + + static const mentionAllValue = 'ALL'; + static const tag = 'mention'; + + @override + bool onMatch(InlineParser parser, Match match) { + parser.addNode(Element.text(tag, match[1].toString())); + return true; + } +} diff --git a/turms-chat-demo-flutter/lib/infra/markdown/resource_syntax.dart b/turms-chat-demo-flutter/lib/infra/markdown/resource_syntax.dart new file mode 100644 index 0000000000..6a792e466d --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/markdown/resource_syntax.dart @@ -0,0 +1,18 @@ +import 'package:markdown/markdown.dart'; + +class ResourceSyntax extends InlineSyntax { + ResourceSyntax() : super(r'^!\[(.*?)]\((https?:\/\/\S+?\.\S+?)\)$'); + + static const tag = 'resource'; + static const attributeAlt = 'alt'; + static const attributeSrc = 'src'; + + @override + bool onMatch(InlineParser parser, Match match) { + final element = Element.empty(tag); + element.attributes[attributeAlt] = match[1].toString(); + element.attributes[attributeSrc] = match[2].toString(); + parser.addNode(element); + return true; + } +} diff --git a/turms-chat-demo-flutter/lib/infra/math/math_utils.dart b/turms-chat-demo-flutter/lib/infra/math/math_utils.dart new file mode 100644 index 0000000000..41ad7a8154 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/math/math_utils.dart @@ -0,0 +1,13 @@ +import 'dart:math'; + +class MathUtils { + MathUtils._(); + + static Point calculatePoint(double centerX, double centerY, + double radius, double distanceFromEdge, double angleInDegrees) { + final angleInRadians = angleInDegrees * (pi / 180); + final x = centerX + (radius - distanceFromEdge) * cos(angleInRadians); + final y = centerY + (radius - distanceFromEdge) * sin(angleInRadians); + return Point(x, y); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/media/audio_utils.dart b/turms-chat-demo-flutter/lib/infra/media/audio_utils.dart new file mode 100644 index 0000000000..08219f4df7 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/media/audio_utils.dart @@ -0,0 +1,13 @@ +import 'package:media_kit/media_kit.dart'; + +class AudioUtils { + AudioUtils._(); + + static Future play(String path) async { + final media = Media(path); + final player = Player(); + await player.stop(); + await player.open(media); + await player.play(); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/media/corrupted_media_file_exception.dart b/turms-chat-demo-flutter/lib/infra/media/corrupted_media_file_exception.dart new file mode 100644 index 0000000000..86270e0e59 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/media/corrupted_media_file_exception.dart @@ -0,0 +1,5 @@ +class CorruptedMediaFileException implements Exception { + const CorruptedMediaFileException([this.message]); + + final String? message; +} diff --git a/turms-chat-demo-flutter/lib/infra/media/future_memory_image_provider.dart b/turms-chat-demo-flutter/lib/infra/media/future_memory_image_provider.dart new file mode 100644 index 0000000000..acc3be099b --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/media/future_memory_image_provider.dart @@ -0,0 +1,57 @@ +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; + +class FutureMemoryImageProvider + extends ImageProvider { + FutureMemoryImageProvider(this.cacheEntryId, this.imageBytes); + + final String cacheEntryId; + final Future imageBytes; + + @override + Future obtainKey( + ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + FutureMemoryImageProvider key, ImageDecoderCallback decode) => + MultiFrameImageStreamCompleter( + codec: _load(decode), + scale: 1.0, + debugLabel: cacheEntryId, + informationCollector: () sync* { + yield ErrorDescription('Cache Entry ID: $cacheEntryId'); + }, + ); + + Future _load(ImageDecoderCallback decode) async { + final bytes = await imageBytes; + if (bytes == null || bytes.lengthInBytes == 0) { + // The file may become available later. + PaintingBinding.instance.imageCache.evict(this); + throw StateError( + '$cacheEntryId is empty and cannot be loaded as an image'); + } + final buffer = await ImmutableBuffer.fromUint8List(bytes); + return decode(buffer); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is FutureMemoryImageProvider && + other.cacheEntryId == cacheEntryId; + } + + @override + int get hashCode => cacheEntryId.hashCode; + + @override + String toString() => + '${objectRuntimeType(this, 'FutureMemoryImageProvider')}("$cacheEntryId")'; +} diff --git a/turms-chat-demo-flutter/lib/infra/media/image_format.dart b/turms-chat-demo-flutter/lib/infra/media/image_format.dart new file mode 100644 index 0000000000..6bb20d6448 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/media/image_format.dart @@ -0,0 +1,7 @@ +enum ImageFormat { + png, + jpeg, + webp, + bmp, + ico, +} diff --git a/turms-chat-demo-flutter/lib/infra/media/image_utils.dart b/turms-chat-demo-flutter/lib/infra/media/image_utils.dart new file mode 100644 index 0000000000..6841596fea --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/media/image_utils.dart @@ -0,0 +1,45 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:image/image.dart' as image; + +import 'image_format.dart'; + +class ImageUtils { + ImageUtils._(); + + static Uint8List crop({ + required image.Image original, + required Offset topLeft, + required Offset bottomRight, + ImageFormat outputFormat = ImageFormat.jpeg, + }) { + if (topLeft.dx.isNegative || + topLeft.dy.isNegative || + bottomRight.dx.isNegative || + bottomRight.dy.isNegative || + topLeft.dx.toInt() > original.width || + topLeft.dy.toInt() > original.height || + bottomRight.dx.toInt() > original.width || + bottomRight.dy.toInt() > original.height) { + throw ArgumentError( + 'Invalid rect: (topLeft: $topLeft, bottomRight: $bottomRight)'); + } + if (topLeft.dx > bottomRight.dx || topLeft.dy > bottomRight.dy) { + throw ArgumentError( + 'Invalid rect: (topLeft: $topLeft, bottomRight: $bottomRight)'); + } + return Uint8List.fromList( + image.encodePng( + image.copyCrop( + original, + x: topLeft.dx.toInt(), + y: topLeft.dy.toInt(), + width: (bottomRight.dx - topLeft.dx).toInt(), + height: (bottomRight.dy - topLeft.dy).toInt(), + ), + ), + ); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/media/video_utils.dart b/turms-chat-demo-flutter/lib/infra/media/video_utils.dart new file mode 100644 index 0000000000..f03cbddb91 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/media/video_utils.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:video_player_media_kit/video_player_media_kit.dart'; + +final class VideoUtils { + VideoUtils._(); + + /// TODO: disable logs: https://github.com/media-kit/media-kit/issues/260 + static void ensureInitialized() { + if (Platform.isAndroid || Platform.isFuchsia) { + VideoPlayerMediaKit.ensureInitialized( + android: true, + ); + } else if (Platform.isIOS) { + VideoPlayerMediaKit.ensureInitialized( + iOS: true, + ); + } else if (Platform.isWindows) { + VideoPlayerMediaKit.ensureInitialized( + windows: true, + ); + } else if (Platform.isMacOS) { + VideoPlayerMediaKit.ensureInitialized( + macOS: true, + ); + } else if (Platform.isLinux) { + VideoPlayerMediaKit.ensureInitialized( + linux: true, + ); + } + } + +// static Future getVideoThumbnail() async { +// final tmpDir = await getTemporaryDirectory(); +// final tmpFile = File('${tmpDir.path}/$name'); +// Video +// if (await tmpFile.exists() == false) { +// await tmpFile.writeAsBytes(bytes); +// } +// try { +// final bytes = await VideoCompress.getByteThumbnail(tmpFile.path); +// if (bytes == null) return null; +// return MatrixImageFile( +// bytes: bytes, +// name: name, +// ); +// } catch (e, s) { +// Logs().w('Error while compressing video', e, s); +// } +// return null; +// } +} diff --git a/turms-chat-demo-flutter/lib/infra/navigation/navigation_utils.dart b/turms-chat-demo-flutter/lib/infra/navigation/navigation_utils.dart new file mode 100644 index 0000000000..b6be19e629 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/navigation/navigation_utils.dart @@ -0,0 +1,39 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; + +class NavigationUtils { + NavigationUtils._(); + + static (KeyEventResult, int?) navigateByKeyEvent( + KeyEvent event, int total, int? currentItemIndex) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return (KeyEventResult.ignored, currentItemIndex); + } + final isArrowUp = event.logicalKey == LogicalKeyboardKey.arrowUp; + final isArrowDown = event.logicalKey == LogicalKeyboardKey.arrowDown; + if (!isArrowUp && !isArrowDown) { + return (KeyEventResult.ignored, currentItemIndex); + } + if (total == 0) { + return (KeyEventResult.handled, currentItemIndex); + } + final conversationTileItemIndex = currentItemIndex; + if (isArrowUp) { + if (conversationTileItemIndex == null) { + return (KeyEventResult.handled, total - 1); + } else if (conversationTileItemIndex > 0) { + return (KeyEventResult.handled, conversationTileItemIndex - 1); + } else { + return (KeyEventResult.handled, total - 1); + } + } else { + if (conversationTileItemIndex == null) { + return (KeyEventResult.handled, 0); + } else if (conversationTileItemIndex < total - 1) { + return (KeyEventResult.handled, conversationTileItemIndex + 1); + } else { + return (KeyEventResult.handled, 0); + } + } + } +} diff --git a/turms-chat-demo-flutter/lib/infra/notification/notification_utils.dart b/turms-chat-demo-flutter/lib/infra/notification/notification_utils.dart new file mode 100644 index 0000000000..562b08136e --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/notification/notification_utils.dart @@ -0,0 +1,62 @@ +import 'dart:io'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:windows_notification/notification_message.dart'; +import 'package:windows_notification/windows_notification.dart'; + +import '../app/app_config.dart'; + +const linuxDetails = LinuxNotificationDetails(); +const darwinDetails = DarwinNotificationDetails(); +int id = 0; + +class NotificationUtils { + NotificationUtils._(); + + static final FlutterLocalNotificationsPlugin _notificationPlugin = + FlutterLocalNotificationsPlugin(); + static final _windowsNotification = + WindowsNotification(applicationId: AppConfig.packageInfo.appName); + + static Future initPlugin() async { + await _notificationPlugin.initialize( + const InitializationSettings( + linux: LinuxInitializationSettings( + defaultActionName: 'Open Notification', + ), + macOS: DarwinInitializationSettings()), + ); + } + + // TODO: Use our custom notification implementation once multi-window is supported: + // https://github.com/flutter/flutter/issues/30701 + static Future showNotification(String header, String body) async { + id++; + if (Platform.isLinux) { + await _notificationPlugin.show( + id, + header, + body, + const NotificationDetails( + linux: linuxDetails, + ), + ); + } else if (Platform.isMacOS) { + await _notificationPlugin.show( + id, + header, + body, + const NotificationDetails( + macOS: darwinDetails, + ), + ); + } else if (Platform.isWindows) { + await _windowsNotification.showNotificationPluginTemplate( + NotificationMessage.fromPluginTemplate( + id.toString(), + header, + body, + )); + } + } +} diff --git a/turms-chat-demo-flutter/lib/infra/platform/platform_helpers.dart b/turms-chat-demo-flutter/lib/infra/platform/platform_helpers.dart new file mode 100644 index 0000000000..7ad000f931 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/platform/platform_helpers.dart @@ -0,0 +1,6 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +final isDesktop = + !kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux); diff --git a/turms-chat-demo-flutter/lib/infra/platform/platform_utils.dart b/turms-chat-demo-flutter/lib/infra/platform/platform_utils.dart new file mode 100644 index 0000000000..06eb8f5423 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/platform/platform_utils.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; + +final class PlatformUtils { + PlatformUtils._(); + + static bool isAnyTargetPlatform(List platforms, + {bool web = false}) { + if (web && kIsWeb) { + return true; + } + return platforms.contains(defaultTargetPlatform); + } + + static bool isTargetPlatform(TargetPlatform platform, {bool web = false}) { + if (web && kIsWeb) { + return true; + } + return defaultTargetPlatform == platform; + } +} diff --git a/turms-chat-demo-flutter/lib/infra/random/random_utils.dart b/turms-chat-demo-flutter/lib/infra/random/random_utils.dart new file mode 100644 index 0000000000..6be637fdf2 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/random/random_utils.dart @@ -0,0 +1,50 @@ +import 'dart:math'; + +import 'package:fixnum/fixnum.dart'; + +// dart web (2^32) +const int _intMaxValue = 4294967296; +const _chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; +const _charCount = _chars.length; + +final _random = Random(); +var _counter = 0; +const _timestampMask = (1 << 47) - 1; +const _counterMask = (1 << 16) - 1; + +final _startTime = DateTime.now().millisecondsSinceEpoch; +final _stopwatch = Stopwatch()..start(); + +class RandomUtils { + RandomUtils._(); + + static bool nextBool() => _random.nextBool(); + + static int nextInt() => _random.nextInt(_intMaxValue); + + static String nextString(int length) { + final stringBuffer = StringBuffer(); + for (var i = 0; i < length; i++) { + final randomIndex = _random.nextInt(_charCount); + stringBuffer.write(_chars[randomIndex]); + } + return stringBuffer.toString(); + } + + static String generateRandomString(int minLength, int maxLength) { + final length = _random.nextInt(maxLength - minLength + 1) + minLength; + final stringBuffer = StringBuffer(); + for (var i = 0; i < length; i++) { + final randomIndex = _random.nextInt(_charCount); + stringBuffer.write(_chars[randomIndex]); + } + return stringBuffer.toString(); + } + + static Int64 nextUniquePositiveInt64() { + final timestamp = + (_startTime + _stopwatch.elapsedMilliseconds) & _timestampMask; + final counter = _counter++; + return Int64((timestamp << 16) | (counter & _counterMask)); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/rpc/rpc_client.dart b/turms-chat-demo-flutter/lib/infra/rpc/rpc_client.dart new file mode 100644 index 0000000000..a69f468122 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/rpc/rpc_client.dart @@ -0,0 +1,42 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import 'rpc_server.dart'; + +class RpcClient { + RpcClient._({required Client client}) : _client = client; + + static Future connect([int? port]) async { + if (port == null) { + final appProcessFile = getAppProcessFile(); + if (!await appProcessFile.exists()) { + throw Exception('An application is not running'); + } + final json = await appProcessFile.readAsString(); + final jsonMap = jsonDecode(json) as Map; + port = jsonMap['port'] as int; + } + final socket = WebSocketChannel.connect(Uri.parse('ws://localhost:$port')); + + await socket.ready; + + final client = Client(socket.cast()); + unawaited(client.listen()); + return RpcClient._(client: client); + } + + final Client _client; + + Future sendHealthcheckRequest() async { + final response = await _client.sendRequest(RpcServer.methodHealthcheck) + as Map; + return response['status'] == 'ok'; + } + + void sendCloseRequest() { + _client.sendNotification(RpcServer.methodClose); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/rpc/rpc_server.dart b/turms-chat-demo-flutter/lib/infra/rpc/rpc_server.dart new file mode 100644 index 0000000000..9066bd5590 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/rpc/rpc_server.dart @@ -0,0 +1,159 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:core'; +import 'dart:io'; + +import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:shelf_web_socket/shelf_web_socket.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../app/app_utils.dart'; +import '../io/path_utils.dart'; +import '../rust/api/system.dart'; +import 'rpc_client.dart'; + +File getAppProcessFile() { + final appProcessFilePath = PathUtils.joinPathInAppScope(['app-process.json']); + return File(appProcessFilePath); +} + +class _AppProcessInfo { + factory _AppProcessInfo.fromJson(Map json) => + _AppProcessInfo( + pid: json['pid'] as int, + port: json['port'] as int, + ); + + const _AppProcessInfo({required this.pid, required this.port}); + + final int pid; + final int port; + + Map toJson() => { + 'pid': pid, + 'port': port, + }; +} + +/// We use WebSocket because: +/// 1. We do want to support communicating with any other applications. +/// so that other developers can develop "plugins" for our application easily. +/// 2. We rarely send RPC requests, +/// so we don't need to acquire the extreme performance. +/// 3. Use WebSockets and JSON-RPC 2.0 so that it is quite easy to debug +/// and extend (e.g. use any tools that support +/// WebSockets and JSON-RPC 2.0 to communicate). +class RpcServer { + RpcServer._( + this._httpServer, this._appProcessFile, this._appProcessRandomAccessFile); + + static const methodHealthcheck = 'healthcheck'; + static const methodClose = 'close'; + + static Future _checkIfApplicationIsAlive(File appProcessFile) async { + final json = await appProcessFile.readAsString(); + _AppProcessInfo info; + try { + if (json.isEmpty) { + return false; + } + final jsonMap = jsonDecode(json) as Map; + info = _AppProcessInfo.fromJson(jsonMap); + } catch (e) { + return false; + } + final isRunning = isProcessRunning(pid: info.pid); + if (!isRunning) { + return false; + } + try { + final client = await RpcClient.connect(info.port); + return await client.sendHealthcheckRequest(); + } catch (e) { + final actualException = e is WebSocketChannelException ? e.inner : e; + if (actualException is SocketException || + (actualException is HttpException && + actualException.message + .toLowerCase() + // e.g.: "Connection closed before full header was received" + .contains('connection closed'))) { + return false; + } + } + return true; + } + + static Future create({int port = 29510}) async { + final appProcessFile = getAppProcessFile(); + if (await appProcessFile.exists()) { + if (await _checkIfApplicationIsAlive(appProcessFile)) { + throw Exception('An application is already running'); + } else { + await appProcessFile.delete(); + } + } else if (!await appProcessFile.parent.exists()) { + await appProcessFile.parent.create(recursive: true); + } + + final handler = webSocketHandler((WebSocketChannel channel) { + final server = Server(channel.cast()) + ..registerMethod(methodHealthcheck, () => {'status': 'ok'}) + ..registerMethod(methodClose, AppUtils.close); + unawaited(server.listen()); + }); + + var file = await appProcessFile.open(mode: FileMode.write); + try { + file = await file.lock(); + } catch (e) { + try { + await file.close(); + } catch (_) {} + rethrow; + } + + HttpServer server; + var retry = 0; + try { + while (true) { + try { + server = await shelf_io.serve(handler, 'localhost', port, + poweredByHeader: null); + } on SocketException catch (e) { + if (e.osError?.message == 'EADDRINUSE') { + if (retry++ >= 1000) { + rethrow; + } + port++; + continue; + } else { + rethrow; + } + } + break; + } + await file.writeString( + jsonEncode(_AppProcessInfo(pid: pid, port: port).toJson())); + } catch (e) { + await file.unlock(); + await file.close(); + rethrow; + } + + return RpcServer._(server, appProcessFile, file); + } + + final HttpServer _httpServer; + final File _appProcessFile; + final RandomAccessFile _appProcessRandomAccessFile; + + int get port => _httpServer.port; + + Future close() async { + await _appProcessRandomAccessFile.unlock(); + await _appProcessRandomAccessFile.close(); + await _appProcessFile.delete(); + return _httpServer.close(); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/rust/api/icu.dart b/turms-chat-demo-flutter/lib/infra/rust/api/icu.dart new file mode 100644 index 0000000000..709dcf5d63 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/rust/api/icu.dart @@ -0,0 +1,22 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.6.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +import '../frb_generated.dart'; + +// These functions are ignored because they are not marked as `pub`: `get_locale_collator`, `init_collator_info` +// These types are ignored because they are not used by any `pub` functions: `CollatorInfo` + +int? compareStrings( + {required String locale, required String s1, required String s2}) => + RustLib.instance.api + .crateApiIcuCompareStrings(locale: locale, s1: s1, s2: s2); + +/// TODO: Use `strings: &[&str]` when supported. +Uint16List? compareStringVec( + {required String locale, required List strings}) => + RustLib.instance.api + .crateApiIcuCompareStringVec(locale: locale, strings: strings); diff --git a/turms-chat-demo-flutter/lib/infra/rust/api/image.dart b/turms-chat-demo-flutter/lib/infra/rust/api/image.dart new file mode 100644 index 0000000000..bb2e144ad6 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/rust/api/image.dart @@ -0,0 +1,50 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.6.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +import '../frb_generated.dart'; + +// These functions are ignored because they are not marked as `pub`: `resize0`, `resize_gif`, `should_resize` + +Future resize( + {required String inputPath, + required String outputPath, + required int width, + required int height}) => + RustLib.instance.api.crateApiImageResize( + inputPath: inputPath, + outputPath: outputPath, + width: width, + height: height); + +enum ResizeError { + decoding, + parameter, + limits, + unsupported, + ioError, + ; +} + +class ResizeResult { + const ResizeResult({ + required this.resized, + this.errorType, + }); + final bool resized; + final ResizeError? errorType; + + @override + int get hashCode => resized.hashCode ^ errorType.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResizeResult && + runtimeType == other.runtimeType && + resized == other.resized && + errorType == other.errorType; +} diff --git a/turms-chat-demo-flutter/lib/infra/rust/api/system.dart b/turms-chat-demo-flutter/lib/infra/rust/api/system.dart new file mode 100644 index 0000000000..4ac2be2388 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/rust/api/system.dart @@ -0,0 +1,37 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.6.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +import '../frb_generated.dart'; + +List getDiskSpaceInfos() => + RustLib.instance.api.crateApiSystemGetDiskSpaceInfos(); + +bool isProcessRunning({required int pid}) => + RustLib.instance.api.crateApiSystemIsProcessRunning(pid: pid); + +class DiskSpaceInfo { + const DiskSpaceInfo({ + required this.path, + required this.total, + required this.available, + }); + final String path; + final BigInt total; + final BigInt available; + + @override + int get hashCode => path.hashCode ^ total.hashCode ^ available.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DiskSpaceInfo && + runtimeType == other.runtimeType && + path == other.path && + total == other.total && + available == other.available; +} diff --git a/turms-chat-demo-flutter/lib/infra/rust/frb_generated.dart b/turms-chat-demo-flutter/lib/infra/rust/frb_generated.dart new file mode 100644 index 0000000000..0a21f9d0d6 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/rust/frb_generated.dart @@ -0,0 +1,717 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.6.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +import 'api/icu.dart'; +import 'api/image.dart'; +import 'api/system.dart'; +import 'frb_generated.dart'; +import 'frb_generated.io.dart' + if (dart.library.js_interop) 'frb_generated.web.dart'; + +/// Main entrypoint of the Rust API +class RustLib extends BaseEntrypoint { + RustLib._(); + @internal + static final instance = RustLib._(); + + /// Initialize flutter_rust_bridge + static Future init({ + RustLibApi? api, + BaseHandler? handler, + ExternalLibrary? externalLibrary, + }) async { + await instance.initImpl( + api: api, + handler: handler, + externalLibrary: externalLibrary, + ); + } + + /// Initialize flutter_rust_bridge in mock mode. + /// No libraries for FFI are loaded. + static void initMock({ + required RustLibApi api, + }) { + instance.initMockImpl( + api: api, + ); + } + + /// Dispose flutter_rust_bridge + /// + /// The call to this function is optional, since flutter_rust_bridge (and everything else) + /// is automatically disposed when the app stops. + static void dispose() => instance.disposeImpl(); + + @override + ApiImplConstructor get apiImplConstructor => + RustLibApiImpl.new; + + @override + WireConstructor get wireConstructor => + RustLibWire.fromExternalLibrary; + + @override + Future executeRustInitializers() async { + await api.crateApiAppInitApp(); + } + + @override + ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig => + kDefaultExternalLibraryLoaderConfig; + + @override + String get codegenVersion => '2.6.0'; + + @override + int get rustContentHash => -1538041608; + + static const kDefaultExternalLibraryLoaderConfig = + ExternalLibraryLoaderConfig( + stem: 'rust_lib_turms_chat_demo', + ioDirectory: 'rust/target/release/', + webPrefix: 'pkg/', + ); +} + +abstract class RustLibApi extends BaseApi { + Uint16List? crateApiIcuCompareStringVec( + {required String locale, required List strings}); + + int? crateApiIcuCompareStrings( + {required String locale, required String s1, required String s2}); + + List crateApiSystemGetDiskSpaceInfos(); + + Future crateApiAppInitApp(); + + bool crateApiSystemIsProcessRunning({required int pid}); + + Future crateApiImageResize( + {required String inputPath, + required String outputPath, + required int width, + required int height}); +} + +class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { + RustLibApiImpl({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @override + Uint16List? crateApiIcuCompareStringVec( + {required String locale, required List strings}) => + handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(locale, serializer); + sse_encode_list_String(strings, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 1)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_opt_list_prim_u_16_strict, + decodeErrorData: null, + ), + constMeta: kCrateApiIcuCompareStringVecConstMeta, + argValues: [locale, strings], + apiImpl: this, + )); + + TaskConstMeta get kCrateApiIcuCompareStringVecConstMeta => + const TaskConstMeta( + debugName: 'compare_string_vec', + argNames: ['locale', 'strings'], + ); + + @override + int? crateApiIcuCompareStrings( + {required String locale, required String s1, required String s2}) => + handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(locale, serializer); + sse_encode_String(s1, serializer); + sse_encode_String(s2, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 2)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_opt_box_autoadd_i_8, + decodeErrorData: null, + ), + constMeta: kCrateApiIcuCompareStringsConstMeta, + argValues: [locale, s1, s2], + apiImpl: this, + )); + + TaskConstMeta get kCrateApiIcuCompareStringsConstMeta => const TaskConstMeta( + debugName: 'compare_strings', + argNames: ['locale', 's1', 's2'], + ); + + @override + List crateApiSystemGetDiskSpaceInfos() => + handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 3)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_disk_space_info, + decodeErrorData: null, + ), + constMeta: kCrateApiSystemGetDiskSpaceInfosConstMeta, + argValues: [], + apiImpl: this, + )); + + TaskConstMeta get kCrateApiSystemGetDiskSpaceInfosConstMeta => + const TaskConstMeta( + debugName: 'get_disk_space_infos', + argNames: [], + ); + + @override + Future crateApiAppInitApp() => handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 4, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiAppInitAppConstMeta, + argValues: [], + apiImpl: this, + )); + + TaskConstMeta get kCrateApiAppInitAppConstMeta => const TaskConstMeta( + debugName: 'init_app', + argNames: [], + ); + + @override + bool crateApiSystemIsProcessRunning({required int pid}) => + handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(pid, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 5)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_bool, + decodeErrorData: null, + ), + constMeta: kCrateApiSystemIsProcessRunningConstMeta, + argValues: [pid], + apiImpl: this, + )); + + TaskConstMeta get kCrateApiSystemIsProcessRunningConstMeta => + const TaskConstMeta( + debugName: 'is_process_running', + argNames: ['pid'], + ); + + @override + Future crateApiImageResize( + {required String inputPath, + required String outputPath, + required int width, + required int height}) => + handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(inputPath, serializer); + sse_encode_String(outputPath, serializer); + sse_encode_u_32(width, serializer); + sse_encode_u_32(height, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 6, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_resize_result, + decodeErrorData: null, + ), + constMeta: kCrateApiImageResizeConstMeta, + argValues: [inputPath, outputPath, width, height], + apiImpl: this, + )); + + TaskConstMeta get kCrateApiImageResizeConstMeta => const TaskConstMeta( + debugName: 'resize', + argNames: ['inputPath', 'outputPath', 'width', 'height'], + ); + + @protected + String dco_decode_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as String; + } + + @protected + bool dco_decode_bool(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as bool; + } + + @protected + int dco_decode_box_autoadd_i_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + ResizeError dco_decode_box_autoadd_resize_error(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_resize_error(raw); + } + + @protected + DiskSpaceInfo dco_decode_disk_space_info(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 3) + throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); + return DiskSpaceInfo( + path: dco_decode_String(arr[0]), + total: dco_decode_u_64(arr[1]), + available: dco_decode_u_64(arr[2]), + ); + } + + @protected + int dco_decode_i_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + int dco_decode_i_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + List dco_decode_list_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_String).toList(); + } + + @protected + List dco_decode_list_disk_space_info(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_disk_space_info).toList(); + } + + @protected + Uint16List dco_decode_list_prim_u_16_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as Uint16List; + } + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as Uint8List; + } + + @protected + int? dco_decode_opt_box_autoadd_i_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_box_autoadd_i_8(raw); + } + + @protected + ResizeError? dco_decode_opt_box_autoadd_resize_error(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_box_autoadd_resize_error(raw); + } + + @protected + Uint16List? dco_decode_opt_list_prim_u_16_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_list_prim_u_16_strict(raw); + } + + @protected + ResizeError dco_decode_resize_error(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return ResizeError.values[raw as int]; + } + + @protected + ResizeResult dco_decode_resize_result(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) + throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return ResizeResult( + resized: dco_decode_bool(arr[0]), + errorType: dco_decode_opt_box_autoadd_resize_error(arr[1]), + ); + } + + @protected + int dco_decode_u_16(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + int dco_decode_u_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + BigInt dco_decode_u_64(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dcoDecodeU64(raw); + } + + @protected + int dco_decode_u_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + void dco_decode_unit(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return; + } + + @protected + String sse_decode_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + final inner = sse_decode_list_prim_u_8_strict(deserializer); + return utf8.decoder.convert(inner); + } + + @protected + bool sse_decode_bool(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8() != 0; + } + + @protected + int sse_decode_box_autoadd_i_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return sse_decode_i_8(deserializer); + } + + @protected + ResizeError sse_decode_box_autoadd_resize_error( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return sse_decode_resize_error(deserializer); + } + + @protected + DiskSpaceInfo sse_decode_disk_space_info(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + final var_path = sse_decode_String(deserializer); + final var_total = sse_decode_u_64(deserializer); + final var_available = sse_decode_u_64(deserializer); + return DiskSpaceInfo( + path: var_path, total: var_total, available: var_available); + } + + @protected + int sse_decode_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt32(); + } + + @protected + int sse_decode_i_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt8(); + } + + @protected + List sse_decode_list_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + final len_ = sse_decode_i_32(deserializer); + final ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_String(deserializer)); + } + return ans_; + } + + @protected + List sse_decode_list_disk_space_info( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + final len_ = sse_decode_i_32(deserializer); + final ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_disk_space_info(deserializer)); + } + return ans_; + } + + @protected + Uint16List sse_decode_list_prim_u_16_strict(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + final len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint16List(len_); + } + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + final len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + int? sse_decode_opt_box_autoadd_i_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return sse_decode_box_autoadd_i_8(deserializer); + } else { + return null; + } + } + + @protected + ResizeError? sse_decode_opt_box_autoadd_resize_error( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return sse_decode_box_autoadd_resize_error(deserializer); + } else { + return null; + } + } + + @protected + Uint16List? sse_decode_opt_list_prim_u_16_strict( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return sse_decode_list_prim_u_16_strict(deserializer); + } else { + return null; + } + } + + @protected + ResizeError sse_decode_resize_error(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + final inner = sse_decode_i_32(deserializer); + return ResizeError.values[inner]; + } + + @protected + ResizeResult sse_decode_resize_result(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + final var_resized = sse_decode_bool(deserializer); + final var_errorType = sse_decode_opt_box_autoadd_resize_error(deserializer); + return ResizeResult(resized: var_resized, errorType: var_errorType); + } + + @protected + int sse_decode_u_16(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint16(); + } + + @protected + int sse_decode_u_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint32(); + } + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getBigUint64(); + } + + @protected + int sse_decode_u_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8(); + } + + @protected + void sse_decode_unit(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + void sse_encode_String(String self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); + } + + @protected + void sse_encode_bool(bool self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self ? 1 : 0); + } + + @protected + void sse_encode_box_autoadd_i_8(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_8(self, serializer); + } + + @protected + void sse_encode_box_autoadd_resize_error( + ResizeError self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_resize_error(self, serializer); + } + + @protected + void sse_encode_disk_space_info( + DiskSpaceInfo self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.path, serializer); + sse_encode_u_64(self.total, serializer); + sse_encode_u_64(self.available, serializer); + } + + @protected + void sse_encode_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt32(self); + } + + @protected + void sse_encode_i_8(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt8(self); + } + + @protected + void sse_encode_list_String(List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_String(item, serializer); + } + } + + @protected + void sse_encode_list_disk_space_info( + List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_disk_space_info(item, serializer); + } + } + + @protected + void sse_encode_list_prim_u_16_strict( + Uint16List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer.putUint16List(self); + } + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer.putUint8List(self); + } + + @protected + void sse_encode_opt_box_autoadd_i_8(int? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_box_autoadd_i_8(self, serializer); + } + } + + @protected + void sse_encode_opt_box_autoadd_resize_error( + ResizeError? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_box_autoadd_resize_error(self, serializer); + } + } + + @protected + void sse_encode_opt_list_prim_u_16_strict( + Uint16List? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_list_prim_u_16_strict(self, serializer); + } + } + + @protected + void sse_encode_resize_error(ResizeError self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.index, serializer); + } + + @protected + void sse_encode_resize_result(ResizeResult self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_bool(self.resized, serializer); + sse_encode_opt_box_autoadd_resize_error(self.errorType, serializer); + } + + @protected + void sse_encode_u_16(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint16(self); + } + + @protected + void sse_encode_u_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint32(self); + } + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putBigUint64(self); + } + + @protected + void sse_encode_u_8(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self); + } + + @protected + void sse_encode_unit(void self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } +} diff --git a/turms-chat-demo-flutter/lib/infra/rust/frb_generated.io.dart b/turms-chat-demo-flutter/lib/infra/rust/frb_generated.io.dart new file mode 100644 index 0000000000..ec99f14d31 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/rust/frb_generated.io.dart @@ -0,0 +1,237 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.6.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi' as ffi; + +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; + +import 'api/icu.dart'; +import 'api/image.dart'; +import 'api/system.dart'; +import 'frb_generated.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + int dco_decode_box_autoadd_i_8(dynamic raw); + + @protected + ResizeError dco_decode_box_autoadd_resize_error(dynamic raw); + + @protected + DiskSpaceInfo dco_decode_disk_space_info(dynamic raw); + + @protected + int dco_decode_i_32(dynamic raw); + + @protected + int dco_decode_i_8(dynamic raw); + + @protected + List dco_decode_list_String(dynamic raw); + + @protected + List dco_decode_list_disk_space_info(dynamic raw); + + @protected + Uint16List dco_decode_list_prim_u_16_strict(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + int? dco_decode_opt_box_autoadd_i_8(dynamic raw); + + @protected + ResizeError? dco_decode_opt_box_autoadd_resize_error(dynamic raw); + + @protected + Uint16List? dco_decode_opt_list_prim_u_16_strict(dynamic raw); + + @protected + ResizeError dco_decode_resize_error(dynamic raw); + + @protected + ResizeResult dco_decode_resize_result(dynamic raw); + + @protected + int dco_decode_u_16(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + BigInt dco_decode_u_64(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + int sse_decode_box_autoadd_i_8(SseDeserializer deserializer); + + @protected + ResizeError sse_decode_box_autoadd_resize_error(SseDeserializer deserializer); + + @protected + DiskSpaceInfo sse_decode_disk_space_info(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + int sse_decode_i_8(SseDeserializer deserializer); + + @protected + List sse_decode_list_String(SseDeserializer deserializer); + + @protected + List sse_decode_list_disk_space_info( + SseDeserializer deserializer); + + @protected + Uint16List sse_decode_list_prim_u_16_strict(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + int? sse_decode_opt_box_autoadd_i_8(SseDeserializer deserializer); + + @protected + ResizeError? sse_decode_opt_box_autoadd_resize_error( + SseDeserializer deserializer); + + @protected + Uint16List? sse_decode_opt_list_prim_u_16_strict( + SseDeserializer deserializer); + + @protected + ResizeError sse_decode_resize_error(SseDeserializer deserializer); + + @protected + ResizeResult sse_decode_resize_result(SseDeserializer deserializer); + + @protected + int sse_decode_u_16(SseDeserializer deserializer); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_box_autoadd_i_8(int self, SseSerializer serializer); + + @protected + void sse_encode_box_autoadd_resize_error( + ResizeError self, SseSerializer serializer); + + @protected + void sse_encode_disk_space_info(DiskSpaceInfo self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + + @protected + void sse_encode_i_8(int self, SseSerializer serializer); + + @protected + void sse_encode_list_String(List self, SseSerializer serializer); + + @protected + void sse_encode_list_disk_space_info( + List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_16_strict( + Uint16List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_opt_box_autoadd_i_8(int? self, SseSerializer serializer); + + @protected + void sse_encode_opt_box_autoadd_resize_error( + ResizeError? self, SseSerializer serializer); + + @protected + void sse_encode_opt_list_prim_u_16_strict( + Uint16List? self, SseSerializer serializer); + + @protected + void sse_encode_resize_error(ResizeError self, SseSerializer serializer); + + @protected + void sse_encode_resize_result(ResizeResult self, SseSerializer serializer); + + @protected + void sse_encode_u_16(int self, SseSerializer serializer); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + /// The symbols are looked up in [dynamicLibrary]. + RustLibWire(ffi.DynamicLibrary dynamicLibrary) + : _lookup = dynamicLibrary.lookup; + + factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) => + RustLibWire(lib.ffiDynamicLibrary); + + /// Holds the symbol lookup function. + final ffi.Pointer Function(String symbolName) + _lookup; +} diff --git a/turms-chat-demo-flutter/lib/infra/rust/frb_generated.web.dart b/turms-chat-demo-flutter/lib/infra/rust/frb_generated.web.dart new file mode 100644 index 0000000000..2bfe2f13f9 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/rust/frb_generated.web.dart @@ -0,0 +1,237 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.6.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +// Static analysis wrongly picks the IO variant, thus ignore this +// ignore_for_file: argument_type_not_assignable + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; + +import 'api/icu.dart'; +import 'api/image.dart'; +import 'api/system.dart'; +import 'frb_generated.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + int dco_decode_box_autoadd_i_8(dynamic raw); + + @protected + ResizeError dco_decode_box_autoadd_resize_error(dynamic raw); + + @protected + DiskSpaceInfo dco_decode_disk_space_info(dynamic raw); + + @protected + int dco_decode_i_32(dynamic raw); + + @protected + int dco_decode_i_8(dynamic raw); + + @protected + List dco_decode_list_String(dynamic raw); + + @protected + List dco_decode_list_disk_space_info(dynamic raw); + + @protected + Uint16List dco_decode_list_prim_u_16_strict(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + int? dco_decode_opt_box_autoadd_i_8(dynamic raw); + + @protected + ResizeError? dco_decode_opt_box_autoadd_resize_error(dynamic raw); + + @protected + Uint16List? dco_decode_opt_list_prim_u_16_strict(dynamic raw); + + @protected + ResizeError dco_decode_resize_error(dynamic raw); + + @protected + ResizeResult dco_decode_resize_result(dynamic raw); + + @protected + int dco_decode_u_16(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + BigInt dco_decode_u_64(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + int sse_decode_box_autoadd_i_8(SseDeserializer deserializer); + + @protected + ResizeError sse_decode_box_autoadd_resize_error(SseDeserializer deserializer); + + @protected + DiskSpaceInfo sse_decode_disk_space_info(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + int sse_decode_i_8(SseDeserializer deserializer); + + @protected + List sse_decode_list_String(SseDeserializer deserializer); + + @protected + List sse_decode_list_disk_space_info( + SseDeserializer deserializer); + + @protected + Uint16List sse_decode_list_prim_u_16_strict(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + int? sse_decode_opt_box_autoadd_i_8(SseDeserializer deserializer); + + @protected + ResizeError? sse_decode_opt_box_autoadd_resize_error( + SseDeserializer deserializer); + + @protected + Uint16List? sse_decode_opt_list_prim_u_16_strict( + SseDeserializer deserializer); + + @protected + ResizeError sse_decode_resize_error(SseDeserializer deserializer); + + @protected + ResizeResult sse_decode_resize_result(SseDeserializer deserializer); + + @protected + int sse_decode_u_16(SseDeserializer deserializer); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_box_autoadd_i_8(int self, SseSerializer serializer); + + @protected + void sse_encode_box_autoadd_resize_error( + ResizeError self, SseSerializer serializer); + + @protected + void sse_encode_disk_space_info(DiskSpaceInfo self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + + @protected + void sse_encode_i_8(int self, SseSerializer serializer); + + @protected + void sse_encode_list_String(List self, SseSerializer serializer); + + @protected + void sse_encode_list_disk_space_info( + List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_16_strict( + Uint16List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_opt_box_autoadd_i_8(int? self, SseSerializer serializer); + + @protected + void sse_encode_opt_box_autoadd_resize_error( + ResizeError? self, SseSerializer serializer); + + @protected + void sse_encode_opt_list_prim_u_16_strict( + Uint16List? self, SseSerializer serializer); + + @protected + void sse_encode_resize_error(ResizeError self, SseSerializer serializer); + + @protected + void sse_encode_resize_result(ResizeResult self, SseSerializer serializer); + + @protected + void sse_encode_u_16(int self, SseSerializer serializer); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + RustLibWire.fromExternalLibrary(); +} + +@JS('wasm_bindgen') +external RustLibWasmModule get wasmModule; + +@JS() +@anonymous +extension type RustLibWasmModule._(JSObject _) implements JSObject {} diff --git a/turms-chat-demo-flutter/lib/infra/shortcut/shortcut.dart b/turms-chat-demo-flutter/lib/infra/shortcut/shortcut.dart new file mode 100644 index 0000000000..ef8ba2a257 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/shortcut/shortcut.dart @@ -0,0 +1,12 @@ +import 'package:flutter/widgets.dart'; + +class Shortcut { + const Shortcut(this.shortcutActivator, this.initialized); + + static const unset = Shortcut(null, false); + + final ShortcutActivator? shortcutActivator; + + /// Used to distinguish between `Shortcut(null, false)` and `Shortcut(null, true)`. + final bool initialized; +} diff --git a/turms-chat-demo-flutter/lib/infra/sqlite/app_database.dart b/turms-chat-demo-flutter/lib/infra/sqlite/app_database.dart new file mode 100644 index 0000000000..16084a1f5e --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/sqlite/app_database.dart @@ -0,0 +1,33 @@ +import 'package:drift/drift.dart'; +import 'package:drift_dev/api/migrations.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; + +import '../../domain/app/tables/app_setting_table.dart'; +import '../../domain/user/tables/user_login_info_table.dart'; +import '../env/env_vars.dart'; +import 'converter/int64_converter.dart'; +import 'core/database_utils.dart'; + +part 'app_database.g.dart'; + +@DriftDatabase(tables: [AppSettingTable, UserLoginInfoTable]) +class AppDatabase extends _$AppDatabase { + AppDatabase(super.e); + + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => + MigrationStrategy(beforeOpen: (details) async { + if (kDebugMode) { + await validateDatabaseSchema(); + } + }); +} + +final appDatabase = AppDatabase(DatabaseUtils.createDatabase( + dbName: 'app', + isAppDatabase: true, + logStatements: EnvVars.databaseLogStatements)); diff --git a/turms-chat-demo-flutter/lib/infra/sqlite/app_database.g.dart b/turms-chat-demo-flutter/lib/infra/sqlite/app_database.g.dart new file mode 100644 index 0000000000..c9eab0d754 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/sqlite/app_database.g.dart @@ -0,0 +1,898 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_database.dart'; + +// ignore_for_file: type=lint +class $AppSettingTableTable extends AppSettingTable + with TableInfo<$AppSettingTableTable, AppSettingTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AppSettingTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + static const VerificationMeta _valueMeta = const VerificationMeta('value'); + @override + late final GeneratedColumn value = GeneratedColumn( + 'value', aliasedName, false, + type: DriftSqlType.any, requiredDuringInsert: true); + static const VerificationMeta _createdDateMeta = + const VerificationMeta('createdDate'); + @override + late final GeneratedColumn createdDate = GeneratedColumn( + 'created_date', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _lastModifiedDateMeta = + const VerificationMeta('lastModifiedDate'); + @override + late final GeneratedColumn lastModifiedDate = + GeneratedColumn('last_modified_date', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + @override + List get $columns => + [id, value, createdDate, lastModifiedDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'app_setting'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('value')) { + context.handle( + _valueMeta, value.isAcceptableOrUnknown(data['value']!, _valueMeta)); + } else if (isInserting) { + context.missing(_valueMeta); + } + if (data.containsKey('created_date')) { + context.handle( + _createdDateMeta, + createdDate.isAcceptableOrUnknown( + data['created_date']!, _createdDateMeta)); + } else if (isInserting) { + context.missing(_createdDateMeta); + } + if (data.containsKey('last_modified_date')) { + context.handle( + _lastModifiedDateMeta, + lastModifiedDate.isAcceptableOrUnknown( + data['last_modified_date']!, _lastModifiedDateMeta)); + } else if (isInserting) { + context.missing(_lastModifiedDateMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + AppSettingTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AppSettingTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + value: attachedDatabase.typeMapping + .read(DriftSqlType.any, data['${effectivePrefix}value'])!, + createdDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_date'])!, + lastModifiedDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}last_modified_date'])!, + ); + } + + @override + $AppSettingTableTable createAlias(String alias) { + return $AppSettingTableTable(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AppSettingTableData extends DataClass + implements Insertable { + final int id; + final DriftAny value; + final DateTime createdDate; + final DateTime lastModifiedDate; + const AppSettingTableData( + {required this.id, + required this.value, + required this.createdDate, + required this.lastModifiedDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['value'] = Variable(value); + map['created_date'] = Variable(createdDate); + map['last_modified_date'] = Variable(lastModifiedDate); + return map; + } + + AppSettingTableCompanion toCompanion(bool nullToAbsent) { + return AppSettingTableCompanion( + id: Value(id), + value: Value(value), + createdDate: Value(createdDate), + lastModifiedDate: Value(lastModifiedDate), + ); + } + + factory AppSettingTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AppSettingTableData( + id: serializer.fromJson(json['id']), + value: serializer.fromJson(json['value']), + createdDate: serializer.fromJson(json['createdDate']), + lastModifiedDate: serializer.fromJson(json['lastModifiedDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'value': serializer.toJson(value), + 'createdDate': serializer.toJson(createdDate), + 'lastModifiedDate': serializer.toJson(lastModifiedDate), + }; + } + + AppSettingTableData copyWith( + {int? id, + DriftAny? value, + DateTime? createdDate, + DateTime? lastModifiedDate}) => + AppSettingTableData( + id: id ?? this.id, + value: value ?? this.value, + createdDate: createdDate ?? this.createdDate, + lastModifiedDate: lastModifiedDate ?? this.lastModifiedDate, + ); + AppSettingTableData copyWithCompanion(AppSettingTableCompanion data) { + return AppSettingTableData( + id: data.id.present ? data.id.value : this.id, + value: data.value.present ? data.value.value : this.value, + createdDate: + data.createdDate.present ? data.createdDate.value : this.createdDate, + lastModifiedDate: data.lastModifiedDate.present + ? data.lastModifiedDate.value + : this.lastModifiedDate, + ); + } + + @override + String toString() { + return (StringBuffer('AppSettingTableData(') + ..write('id: $id, ') + ..write('value: $value, ') + ..write('createdDate: $createdDate, ') + ..write('lastModifiedDate: $lastModifiedDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, value, createdDate, lastModifiedDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AppSettingTableData && + other.id == this.id && + other.value == this.value && + other.createdDate == this.createdDate && + other.lastModifiedDate == this.lastModifiedDate); +} + +class AppSettingTableCompanion extends UpdateCompanion { + final Value id; + final Value value; + final Value createdDate; + final Value lastModifiedDate; + const AppSettingTableCompanion({ + this.id = const Value.absent(), + this.value = const Value.absent(), + this.createdDate = const Value.absent(), + this.lastModifiedDate = const Value.absent(), + }); + AppSettingTableCompanion.insert({ + required int id, + required DriftAny value, + required DateTime createdDate, + required DateTime lastModifiedDate, + }) : id = Value(id), + value = Value(value), + createdDate = Value(createdDate), + lastModifiedDate = Value(lastModifiedDate); + static Insertable custom({ + Expression? id, + Expression? value, + Expression? createdDate, + Expression? lastModifiedDate, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (value != null) 'value': value, + if (createdDate != null) 'created_date': createdDate, + if (lastModifiedDate != null) 'last_modified_date': lastModifiedDate, + }); + } + + AppSettingTableCompanion copyWith( + {Value? id, + Value? value, + Value? createdDate, + Value? lastModifiedDate}) { + return AppSettingTableCompanion( + id: id ?? this.id, + value: value ?? this.value, + createdDate: createdDate ?? this.createdDate, + lastModifiedDate: lastModifiedDate ?? this.lastModifiedDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (createdDate.present) { + map['created_date'] = Variable(createdDate.value); + } + if (lastModifiedDate.present) { + map['last_modified_date'] = Variable(lastModifiedDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AppSettingTableCompanion(') + ..write('id: $id, ') + ..write('value: $value, ') + ..write('createdDate: $createdDate, ') + ..write('lastModifiedDate: $lastModifiedDate') + ..write(')')) + .toString(); + } +} + +class $UserLoginInfoTableTable extends UserLoginInfoTable + with TableInfo<$UserLoginInfoTableTable, UserLoginInfoTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $UserLoginInfoTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _userIdMeta = const VerificationMeta('userId'); + @override + late final GeneratedColumnWithTypeConverter userId = + GeneratedColumn('user_id', aliasedName, false, + type: DriftSqlType.bigInt, requiredDuringInsert: true) + .withConverter($UserLoginInfoTableTable.$converteruserId); + static const VerificationMeta _passwordMeta = + const VerificationMeta('password'); + @override + late final GeneratedColumn password = GeneratedColumn( + 'password', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _createdDateMeta = + const VerificationMeta('createdDate'); + @override + late final GeneratedColumn createdDate = GeneratedColumn( + 'created_date', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _lastModifiedDateMeta = + const VerificationMeta('lastModifiedDate'); + @override + late final GeneratedColumn lastModifiedDate = + GeneratedColumn('last_modified_date', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + @override + List get $columns => + [userId, password, createdDate, lastModifiedDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_login_info'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + context.handle(_userIdMeta, const VerificationResult.success()); + if (data.containsKey('password')) { + context.handle(_passwordMeta, + password.isAcceptableOrUnknown(data['password']!, _passwordMeta)); + } else if (isInserting) { + context.missing(_passwordMeta); + } + if (data.containsKey('created_date')) { + context.handle( + _createdDateMeta, + createdDate.isAcceptableOrUnknown( + data['created_date']!, _createdDateMeta)); + } else if (isInserting) { + context.missing(_createdDateMeta); + } + if (data.containsKey('last_modified_date')) { + context.handle( + _lastModifiedDateMeta, + lastModifiedDate.isAcceptableOrUnknown( + data['last_modified_date']!, _lastModifiedDateMeta)); + } else if (isInserting) { + context.missing(_lastModifiedDateMeta); + } + return context; + } + + @override + Set get $primaryKey => {userId}; + @override + UserLoginInfoTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserLoginInfoTableData( + userId: $UserLoginInfoTableTable.$converteruserId.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.bigInt, data['${effectivePrefix}user_id'])!), + password: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}password'])!, + createdDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_date'])!, + lastModifiedDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}last_modified_date'])!, + ); + } + + @override + $UserLoginInfoTableTable createAlias(String alias) { + return $UserLoginInfoTableTable(attachedDatabase, alias); + } + + static TypeConverter $converteruserId = const Int64Converter(); + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserLoginInfoTableData extends DataClass + implements Insertable { + final Int64 userId; + final String password; + final DateTime createdDate; + final DateTime lastModifiedDate; + const UserLoginInfoTableData( + {required this.userId, + required this.password, + required this.createdDate, + required this.lastModifiedDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + { + map['user_id'] = Variable( + $UserLoginInfoTableTable.$converteruserId.toSql(userId)); + } + map['password'] = Variable(password); + map['created_date'] = Variable(createdDate); + map['last_modified_date'] = Variable(lastModifiedDate); + return map; + } + + UserLoginInfoTableCompanion toCompanion(bool nullToAbsent) { + return UserLoginInfoTableCompanion( + userId: Value(userId), + password: Value(password), + createdDate: Value(createdDate), + lastModifiedDate: Value(lastModifiedDate), + ); + } + + factory UserLoginInfoTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserLoginInfoTableData( + userId: serializer.fromJson(json['userId']), + password: serializer.fromJson(json['password']), + createdDate: serializer.fromJson(json['createdDate']), + lastModifiedDate: serializer.fromJson(json['lastModifiedDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'userId': serializer.toJson(userId), + 'password': serializer.toJson(password), + 'createdDate': serializer.toJson(createdDate), + 'lastModifiedDate': serializer.toJson(lastModifiedDate), + }; + } + + UserLoginInfoTableData copyWith( + {Int64? userId, + String? password, + DateTime? createdDate, + DateTime? lastModifiedDate}) => + UserLoginInfoTableData( + userId: userId ?? this.userId, + password: password ?? this.password, + createdDate: createdDate ?? this.createdDate, + lastModifiedDate: lastModifiedDate ?? this.lastModifiedDate, + ); + UserLoginInfoTableData copyWithCompanion(UserLoginInfoTableCompanion data) { + return UserLoginInfoTableData( + userId: data.userId.present ? data.userId.value : this.userId, + password: data.password.present ? data.password.value : this.password, + createdDate: + data.createdDate.present ? data.createdDate.value : this.createdDate, + lastModifiedDate: data.lastModifiedDate.present + ? data.lastModifiedDate.value + : this.lastModifiedDate, + ); + } + + @override + String toString() { + return (StringBuffer('UserLoginInfoTableData(') + ..write('userId: $userId, ') + ..write('password: $password, ') + ..write('createdDate: $createdDate, ') + ..write('lastModifiedDate: $lastModifiedDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(userId, password, createdDate, lastModifiedDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserLoginInfoTableData && + other.userId == this.userId && + other.password == this.password && + other.createdDate == this.createdDate && + other.lastModifiedDate == this.lastModifiedDate); +} + +class UserLoginInfoTableCompanion + extends UpdateCompanion { + final Value userId; + final Value password; + final Value createdDate; + final Value lastModifiedDate; + const UserLoginInfoTableCompanion({ + this.userId = const Value.absent(), + this.password = const Value.absent(), + this.createdDate = const Value.absent(), + this.lastModifiedDate = const Value.absent(), + }); + UserLoginInfoTableCompanion.insert({ + required Int64 userId, + required String password, + required DateTime createdDate, + required DateTime lastModifiedDate, + }) : userId = Value(userId), + password = Value(password), + createdDate = Value(createdDate), + lastModifiedDate = Value(lastModifiedDate); + static Insertable custom({ + Expression? userId, + Expression? password, + Expression? createdDate, + Expression? lastModifiedDate, + }) { + return RawValuesInsertable({ + if (userId != null) 'user_id': userId, + if (password != null) 'password': password, + if (createdDate != null) 'created_date': createdDate, + if (lastModifiedDate != null) 'last_modified_date': lastModifiedDate, + }); + } + + UserLoginInfoTableCompanion copyWith( + {Value? userId, + Value? password, + Value? createdDate, + Value? lastModifiedDate}) { + return UserLoginInfoTableCompanion( + userId: userId ?? this.userId, + password: password ?? this.password, + createdDate: createdDate ?? this.createdDate, + lastModifiedDate: lastModifiedDate ?? this.lastModifiedDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (userId.present) { + map['user_id'] = Variable( + $UserLoginInfoTableTable.$converteruserId.toSql(userId.value)); + } + if (password.present) { + map['password'] = Variable(password.value); + } + if (createdDate.present) { + map['created_date'] = Variable(createdDate.value); + } + if (lastModifiedDate.present) { + map['last_modified_date'] = Variable(lastModifiedDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserLoginInfoTableCompanion(') + ..write('userId: $userId, ') + ..write('password: $password, ') + ..write('createdDate: $createdDate, ') + ..write('lastModifiedDate: $lastModifiedDate') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $AppSettingTableTable appSettingTable = + $AppSettingTableTable(this); + late final $UserLoginInfoTableTable userLoginInfoTable = + $UserLoginInfoTableTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => + [appSettingTable, userLoginInfoTable]; +} + +typedef $$AppSettingTableTableCreateCompanionBuilder = AppSettingTableCompanion + Function({ + required int id, + required DriftAny value, + required DateTime createdDate, + required DateTime lastModifiedDate, +}); +typedef $$AppSettingTableTableUpdateCompanionBuilder = AppSettingTableCompanion + Function({ + Value id, + Value value, + Value createdDate, + Value lastModifiedDate, +}); + +class $$AppSettingTableTableFilterComposer + extends Composer<_$AppDatabase, $AppSettingTableTable> { + $$AppSettingTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get value => $composableBuilder( + column: $table.value, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => ColumnFilters(column)); + + ColumnFilters get lastModifiedDate => $composableBuilder( + column: $table.lastModifiedDate, + builder: (column) => ColumnFilters(column)); +} + +class $$AppSettingTableTableOrderingComposer + extends Composer<_$AppDatabase, $AppSettingTableTable> { + $$AppSettingTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get value => $composableBuilder( + column: $table.value, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get lastModifiedDate => $composableBuilder( + column: $table.lastModifiedDate, + builder: (column) => ColumnOrderings(column)); +} + +class $$AppSettingTableTableAnnotationComposer + extends Composer<_$AppDatabase, $AppSettingTableTable> { + $$AppSettingTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); + + GeneratedColumn get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => column); + + GeneratedColumn get lastModifiedDate => $composableBuilder( + column: $table.lastModifiedDate, builder: (column) => column); +} + +class $$AppSettingTableTableTableManager extends RootTableManager< + _$AppDatabase, + $AppSettingTableTable, + AppSettingTableData, + $$AppSettingTableTableFilterComposer, + $$AppSettingTableTableOrderingComposer, + $$AppSettingTableTableAnnotationComposer, + $$AppSettingTableTableCreateCompanionBuilder, + $$AppSettingTableTableUpdateCompanionBuilder, + ( + AppSettingTableData, + BaseReferences<_$AppDatabase, $AppSettingTableTable, AppSettingTableData> + ), + AppSettingTableData, + PrefetchHooks Function()> { + $$AppSettingTableTableTableManager( + _$AppDatabase db, $AppSettingTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$AppSettingTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$AppSettingTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$AppSettingTableTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value value = const Value.absent(), + Value createdDate = const Value.absent(), + Value lastModifiedDate = const Value.absent(), + }) => + AppSettingTableCompanion( + id: id, + value: value, + createdDate: createdDate, + lastModifiedDate: lastModifiedDate, + ), + createCompanionCallback: ({ + required int id, + required DriftAny value, + required DateTime createdDate, + required DateTime lastModifiedDate, + }) => + AppSettingTableCompanion.insert( + id: id, + value: value, + createdDate: createdDate, + lastModifiedDate: lastModifiedDate, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$AppSettingTableTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $AppSettingTableTable, + AppSettingTableData, + $$AppSettingTableTableFilterComposer, + $$AppSettingTableTableOrderingComposer, + $$AppSettingTableTableAnnotationComposer, + $$AppSettingTableTableCreateCompanionBuilder, + $$AppSettingTableTableUpdateCompanionBuilder, + ( + AppSettingTableData, + BaseReferences<_$AppDatabase, $AppSettingTableTable, AppSettingTableData> + ), + AppSettingTableData, + PrefetchHooks Function()>; +typedef $$UserLoginInfoTableTableCreateCompanionBuilder + = UserLoginInfoTableCompanion Function({ + required Int64 userId, + required String password, + required DateTime createdDate, + required DateTime lastModifiedDate, +}); +typedef $$UserLoginInfoTableTableUpdateCompanionBuilder + = UserLoginInfoTableCompanion Function({ + Value userId, + Value password, + Value createdDate, + Value lastModifiedDate, +}); + +class $$UserLoginInfoTableTableFilterComposer + extends Composer<_$AppDatabase, $UserLoginInfoTableTable> { + $$UserLoginInfoTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnWithTypeConverterFilters get userId => + $composableBuilder( + column: $table.userId, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnFilters get password => $composableBuilder( + column: $table.password, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => ColumnFilters(column)); + + ColumnFilters get lastModifiedDate => $composableBuilder( + column: $table.lastModifiedDate, + builder: (column) => ColumnFilters(column)); +} + +class $$UserLoginInfoTableTableOrderingComposer + extends Composer<_$AppDatabase, $UserLoginInfoTableTable> { + $$UserLoginInfoTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get userId => $composableBuilder( + column: $table.userId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get password => $composableBuilder( + column: $table.password, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get lastModifiedDate => $composableBuilder( + column: $table.lastModifiedDate, + builder: (column) => ColumnOrderings(column)); +} + +class $$UserLoginInfoTableTableAnnotationComposer + extends Composer<_$AppDatabase, $UserLoginInfoTableTable> { + $$UserLoginInfoTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumnWithTypeConverter get userId => + $composableBuilder(column: $table.userId, builder: (column) => column); + + GeneratedColumn get password => + $composableBuilder(column: $table.password, builder: (column) => column); + + GeneratedColumn get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => column); + + GeneratedColumn get lastModifiedDate => $composableBuilder( + column: $table.lastModifiedDate, builder: (column) => column); +} + +class $$UserLoginInfoTableTableTableManager extends RootTableManager< + _$AppDatabase, + $UserLoginInfoTableTable, + UserLoginInfoTableData, + $$UserLoginInfoTableTableFilterComposer, + $$UserLoginInfoTableTableOrderingComposer, + $$UserLoginInfoTableTableAnnotationComposer, + $$UserLoginInfoTableTableCreateCompanionBuilder, + $$UserLoginInfoTableTableUpdateCompanionBuilder, + ( + UserLoginInfoTableData, + BaseReferences<_$AppDatabase, $UserLoginInfoTableTable, + UserLoginInfoTableData> + ), + UserLoginInfoTableData, + PrefetchHooks Function()> { + $$UserLoginInfoTableTableTableManager( + _$AppDatabase db, $UserLoginInfoTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$UserLoginInfoTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$UserLoginInfoTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$UserLoginInfoTableTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + Value userId = const Value.absent(), + Value password = const Value.absent(), + Value createdDate = const Value.absent(), + Value lastModifiedDate = const Value.absent(), + }) => + UserLoginInfoTableCompanion( + userId: userId, + password: password, + createdDate: createdDate, + lastModifiedDate: lastModifiedDate, + ), + createCompanionCallback: ({ + required Int64 userId, + required String password, + required DateTime createdDate, + required DateTime lastModifiedDate, + }) => + UserLoginInfoTableCompanion.insert( + userId: userId, + password: password, + createdDate: createdDate, + lastModifiedDate: lastModifiedDate, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$UserLoginInfoTableTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $UserLoginInfoTableTable, + UserLoginInfoTableData, + $$UserLoginInfoTableTableFilterComposer, + $$UserLoginInfoTableTableOrderingComposer, + $$UserLoginInfoTableTableAnnotationComposer, + $$UserLoginInfoTableTableCreateCompanionBuilder, + $$UserLoginInfoTableTableUpdateCompanionBuilder, + ( + UserLoginInfoTableData, + BaseReferences<_$AppDatabase, $UserLoginInfoTableTable, + UserLoginInfoTableData> + ), + UserLoginInfoTableData, + PrefetchHooks Function()>; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$AppSettingTableTableTableManager get appSettingTable => + $$AppSettingTableTableTableManager(_db, _db.appSettingTable); + $$UserLoginInfoTableTableTableManager get userLoginInfoTable => + $$UserLoginInfoTableTableTableManager(_db, _db.userLoginInfoTable); +} diff --git a/turms-chat-demo-flutter/lib/infra/sqlite/converter/int64_converter.dart b/turms-chat-demo-flutter/lib/infra/sqlite/converter/int64_converter.dart new file mode 100644 index 0000000000..f9ffcc38b1 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/sqlite/converter/int64_converter.dart @@ -0,0 +1,14 @@ +import 'package:drift/drift.dart'; +import 'package:fixnum/fixnum.dart'; + +import '../../built_in_types/built_in_type_helpers.dart'; + +class Int64Converter extends TypeConverter { + const Int64Converter(); + + @override + Int64 fromSql(BigInt fromDb) => fromDb.toInt64(); + + @override + BigInt toSql(Int64 value) => value.toBigInt(); +} diff --git a/turms-chat-demo-flutter/lib/infra/sqlite/converter/uint8_matrix_converter.dart b/turms-chat-demo-flutter/lib/infra/sqlite/converter/uint8_matrix_converter.dart new file mode 100644 index 0000000000..b2fc4f5ae3 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/sqlite/converter/uint8_matrix_converter.dart @@ -0,0 +1,14 @@ +import 'package:drift/drift.dart'; + +import '../../codec/codec_utils.dart'; + +class Uint8MatrixConverter extends TypeConverter, Uint8List> { + const Uint8MatrixConverter(); + + @override + List fromSql(Uint8List fromDb) => + CodecUtils.deserialize2DArray(fromDb); + + @override + Uint8List toSql(List value) => CodecUtils.serialize2DArray(value); +} diff --git a/turms-chat-demo-flutter/lib/infra/sqlite/core/database_utils.dart b/turms-chat-demo-flutter/lib/infra/sqlite/core/database_utils.dart new file mode 100644 index 0000000000..6a18e769bb --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/sqlite/core/database_utils.dart @@ -0,0 +1,3 @@ +export 'database_utils_unsupported.dart' + if (dart.library.io) 'database_utils_native.dart' + if (dart.library.html) 'database_utils_web.dart'; diff --git a/turms-chat-demo-flutter/lib/infra/sqlite/core/database_utils_native.dart b/turms-chat-demo-flutter/lib/infra/sqlite/core/database_utils_native.dart new file mode 100644 index 0000000000..0fa463a983 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/sqlite/core/database_utils_native.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; + +import '../../io/path_utils.dart'; + +class DatabaseUtils { + DatabaseUtils._(); + + static QueryExecutor createDatabase({ + required String dbName, + required bool isAppDatabase, + bool inMemory = false, + required bool logStatements, + }) { + if (inMemory) { + return NativeDatabase.memory(logStatements: logStatements); + } + return LazyDatabase(() async { + final path = isAppDatabase + ? PathUtils.joinPathInAppScope(['database', '$dbName.sqlite']) + : PathUtils.joinPathInUserScope(['database', '$dbName.sqlite']); + final file = File(path); + return NativeDatabase.createInBackground(file, setup: (database) { + // Configure for better performance. + database + ..execute('PRAGMA journal_mode=WAL;') + ..execute('PRAGMA synchronous=NORMAL;'); + }, logStatements: logStatements); + }); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/sqlite/core/database_utils_unsupported.dart b/turms-chat-demo-flutter/lib/infra/sqlite/core/database_utils_unsupported.dart new file mode 100644 index 0000000000..b0ed3ccd57 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/sqlite/core/database_utils_unsupported.dart @@ -0,0 +1,16 @@ +import 'package:drift/backends.dart'; + +class DatabaseUtils { + DatabaseUtils._(); + + static QueryExecutor createDatabase({ + required String dbName, + required bool isAppDatabase, + bool inMemory = false, + required bool logStatements, + }) { + throw UnsupportedError( + 'Unsupported platform', + ); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/sqlite/core/database_utils_web.dart b/turms-chat-demo-flutter/lib/infra/sqlite/core/database_utils_web.dart new file mode 100644 index 0000000000..f881a1b9b5 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/sqlite/core/database_utils_web.dart @@ -0,0 +1,20 @@ +import 'package:drift/backends.dart'; +import 'package:drift/web.dart'; + +class DatabaseUtils { + DatabaseUtils._(); + + static QueryExecutor createDatabase({ + required String dbName, + required bool isAppDatabase, + bool inMemory = false, + required bool logStatements, + }) => + inMemory + ? WebDatabase.withStorage(DriftWebStorage.volatile(), + logStatements: logStatements, + // We set this to true because we need to store int64 + readIntsAsBigInt: true) + : WebDatabase('$dbName.sqlite', + logStatements: logStatements, readIntsAsBigInt: true); +} diff --git a/turms-chat-demo-flutter/lib/infra/sqlite/user_database.dart b/turms-chat-demo-flutter/lib/infra/sqlite/user_database.dart new file mode 100644 index 0000000000..a152a99027 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/sqlite/user_database.dart @@ -0,0 +1,41 @@ +import 'package:drift/drift.dart'; +import 'package:drift_dev/api/migrations.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; + +import '../../domain/app/tables/log_entry_table.dart'; +import '../../domain/app/tables/log_level_converter.dart'; +import '../../domain/conversation/tables/conversation_setting_table.dart'; +import '../../domain/user/tables/user_setting_table.dart'; +import '../env/env_vars.dart'; +import '../logging/log_level.dart'; +import 'core/database_utils.dart'; + +part 'user_database.g.dart'; + +@DriftDatabase( + tables: [ConversationSettingTable, LogEntryTable, UserSettingTable]) +class UserDatabase extends _$UserDatabase { + UserDatabase(super.e); + + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => + MigrationStrategy(beforeOpen: (details) async { + if (kDebugMode) { + await validateDatabaseSchema(); + } + }); +} + +final _userIdToDatabase = {}; + +UserDatabase createUserDatabaseIfNotExists(Int64 userId) => + _userIdToDatabase.putIfAbsent( + userId, + () => UserDatabase(DatabaseUtils.createDatabase( + dbName: 'user_${userId.toString()}', + isAppDatabase: false, + logStatements: EnvVars.databaseLogStatements))); diff --git a/turms-chat-demo-flutter/lib/infra/sqlite/user_database.g.dart b/turms-chat-demo-flutter/lib/infra/sqlite/user_database.g.dart new file mode 100644 index 0000000000..5ffca860a2 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/sqlite/user_database.g.dart @@ -0,0 +1,1450 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_database.dart'; + +// ignore_for_file: type=lint +class $ConversationSettingTableTable extends ConversationSettingTable + with + TableInfo<$ConversationSettingTableTable, + ConversationSettingTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ConversationSettingTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _contactIdMeta = + const VerificationMeta('contactId'); + @override + late final GeneratedColumn contactId = GeneratedColumn( + 'contact_id', aliasedName, false, + type: DriftSqlType.bigInt, requiredDuringInsert: true); + static const VerificationMeta _isGroupConversationMeta = + const VerificationMeta('isGroupConversation'); + @override + late final GeneratedColumn isGroupConversation = GeneratedColumn( + 'is_group_conversation', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_group_conversation" IN (0, 1))')); + static const VerificationMeta _settingIdMeta = + const VerificationMeta('settingId'); + @override + late final GeneratedColumn settingId = GeneratedColumn( + 'setting_id', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + static const VerificationMeta _valueMeta = const VerificationMeta('value'); + @override + late final GeneratedColumn value = GeneratedColumn( + 'value', aliasedName, false, + type: DriftSqlType.any, requiredDuringInsert: true); + static const VerificationMeta _createdDateMeta = + const VerificationMeta('createdDate'); + @override + late final GeneratedColumn createdDate = GeneratedColumn( + 'created_date', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _lastModifiedDateMeta = + const VerificationMeta('lastModifiedDate'); + @override + late final GeneratedColumn lastModifiedDate = + GeneratedColumn('last_modified_date', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + @override + List get $columns => [ + contactId, + isGroupConversation, + settingId, + value, + createdDate, + lastModifiedDate + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'conversation_setting'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('contact_id')) { + context.handle(_contactIdMeta, + contactId.isAcceptableOrUnknown(data['contact_id']!, _contactIdMeta)); + } else if (isInserting) { + context.missing(_contactIdMeta); + } + if (data.containsKey('is_group_conversation')) { + context.handle( + _isGroupConversationMeta, + isGroupConversation.isAcceptableOrUnknown( + data['is_group_conversation']!, _isGroupConversationMeta)); + } else if (isInserting) { + context.missing(_isGroupConversationMeta); + } + if (data.containsKey('setting_id')) { + context.handle(_settingIdMeta, + settingId.isAcceptableOrUnknown(data['setting_id']!, _settingIdMeta)); + } else if (isInserting) { + context.missing(_settingIdMeta); + } + if (data.containsKey('value')) { + context.handle( + _valueMeta, value.isAcceptableOrUnknown(data['value']!, _valueMeta)); + } else if (isInserting) { + context.missing(_valueMeta); + } + if (data.containsKey('created_date')) { + context.handle( + _createdDateMeta, + createdDate.isAcceptableOrUnknown( + data['created_date']!, _createdDateMeta)); + } else if (isInserting) { + context.missing(_createdDateMeta); + } + if (data.containsKey('last_modified_date')) { + context.handle( + _lastModifiedDateMeta, + lastModifiedDate.isAcceptableOrUnknown( + data['last_modified_date']!, _lastModifiedDateMeta)); + } else if (isInserting) { + context.missing(_lastModifiedDateMeta); + } + return context; + } + + @override + Set get $primaryKey => + {contactId, isGroupConversation, settingId}; + @override + ConversationSettingTableData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ConversationSettingTableData( + contactId: attachedDatabase.typeMapping + .read(DriftSqlType.bigInt, data['${effectivePrefix}contact_id'])!, + isGroupConversation: attachedDatabase.typeMapping.read( + DriftSqlType.bool, data['${effectivePrefix}is_group_conversation'])!, + settingId: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}setting_id'])!, + value: attachedDatabase.typeMapping + .read(DriftSqlType.any, data['${effectivePrefix}value'])!, + createdDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_date'])!, + lastModifiedDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}last_modified_date'])!, + ); + } + + @override + $ConversationSettingTableTable createAlias(String alias) { + return $ConversationSettingTableTable(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class ConversationSettingTableData extends DataClass + implements Insertable { + /// This can be either a group ID or a user (recipient) ID. + final BigInt contactId; + final bool isGroupConversation; + final int settingId; + final DriftAny value; + final DateTime createdDate; + final DateTime lastModifiedDate; + const ConversationSettingTableData( + {required this.contactId, + required this.isGroupConversation, + required this.settingId, + required this.value, + required this.createdDate, + required this.lastModifiedDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['contact_id'] = Variable(contactId); + map['is_group_conversation'] = Variable(isGroupConversation); + map['setting_id'] = Variable(settingId); + map['value'] = Variable(value); + map['created_date'] = Variable(createdDate); + map['last_modified_date'] = Variable(lastModifiedDate); + return map; + } + + ConversationSettingTableCompanion toCompanion(bool nullToAbsent) { + return ConversationSettingTableCompanion( + contactId: Value(contactId), + isGroupConversation: Value(isGroupConversation), + settingId: Value(settingId), + value: Value(value), + createdDate: Value(createdDate), + lastModifiedDate: Value(lastModifiedDate), + ); + } + + factory ConversationSettingTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ConversationSettingTableData( + contactId: serializer.fromJson(json['contactId']), + isGroupConversation: + serializer.fromJson(json['isGroupConversation']), + settingId: serializer.fromJson(json['settingId']), + value: serializer.fromJson(json['value']), + createdDate: serializer.fromJson(json['createdDate']), + lastModifiedDate: serializer.fromJson(json['lastModifiedDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'contactId': serializer.toJson(contactId), + 'isGroupConversation': serializer.toJson(isGroupConversation), + 'settingId': serializer.toJson(settingId), + 'value': serializer.toJson(value), + 'createdDate': serializer.toJson(createdDate), + 'lastModifiedDate': serializer.toJson(lastModifiedDate), + }; + } + + ConversationSettingTableData copyWith( + {BigInt? contactId, + bool? isGroupConversation, + int? settingId, + DriftAny? value, + DateTime? createdDate, + DateTime? lastModifiedDate}) => + ConversationSettingTableData( + contactId: contactId ?? this.contactId, + isGroupConversation: isGroupConversation ?? this.isGroupConversation, + settingId: settingId ?? this.settingId, + value: value ?? this.value, + createdDate: createdDate ?? this.createdDate, + lastModifiedDate: lastModifiedDate ?? this.lastModifiedDate, + ); + ConversationSettingTableData copyWithCompanion( + ConversationSettingTableCompanion data) { + return ConversationSettingTableData( + contactId: data.contactId.present ? data.contactId.value : this.contactId, + isGroupConversation: data.isGroupConversation.present + ? data.isGroupConversation.value + : this.isGroupConversation, + settingId: data.settingId.present ? data.settingId.value : this.settingId, + value: data.value.present ? data.value.value : this.value, + createdDate: + data.createdDate.present ? data.createdDate.value : this.createdDate, + lastModifiedDate: data.lastModifiedDate.present + ? data.lastModifiedDate.value + : this.lastModifiedDate, + ); + } + + @override + String toString() { + return (StringBuffer('ConversationSettingTableData(') + ..write('contactId: $contactId, ') + ..write('isGroupConversation: $isGroupConversation, ') + ..write('settingId: $settingId, ') + ..write('value: $value, ') + ..write('createdDate: $createdDate, ') + ..write('lastModifiedDate: $lastModifiedDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(contactId, isGroupConversation, settingId, + value, createdDate, lastModifiedDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ConversationSettingTableData && + other.contactId == this.contactId && + other.isGroupConversation == this.isGroupConversation && + other.settingId == this.settingId && + other.value == this.value && + other.createdDate == this.createdDate && + other.lastModifiedDate == this.lastModifiedDate); +} + +class ConversationSettingTableCompanion + extends UpdateCompanion { + final Value contactId; + final Value isGroupConversation; + final Value settingId; + final Value value; + final Value createdDate; + final Value lastModifiedDate; + const ConversationSettingTableCompanion({ + this.contactId = const Value.absent(), + this.isGroupConversation = const Value.absent(), + this.settingId = const Value.absent(), + this.value = const Value.absent(), + this.createdDate = const Value.absent(), + this.lastModifiedDate = const Value.absent(), + }); + ConversationSettingTableCompanion.insert({ + required BigInt contactId, + required bool isGroupConversation, + required int settingId, + required DriftAny value, + required DateTime createdDate, + required DateTime lastModifiedDate, + }) : contactId = Value(contactId), + isGroupConversation = Value(isGroupConversation), + settingId = Value(settingId), + value = Value(value), + createdDate = Value(createdDate), + lastModifiedDate = Value(lastModifiedDate); + static Insertable custom({ + Expression? contactId, + Expression? isGroupConversation, + Expression? settingId, + Expression? value, + Expression? createdDate, + Expression? lastModifiedDate, + }) { + return RawValuesInsertable({ + if (contactId != null) 'contact_id': contactId, + if (isGroupConversation != null) + 'is_group_conversation': isGroupConversation, + if (settingId != null) 'setting_id': settingId, + if (value != null) 'value': value, + if (createdDate != null) 'created_date': createdDate, + if (lastModifiedDate != null) 'last_modified_date': lastModifiedDate, + }); + } + + ConversationSettingTableCompanion copyWith( + {Value? contactId, + Value? isGroupConversation, + Value? settingId, + Value? value, + Value? createdDate, + Value? lastModifiedDate}) { + return ConversationSettingTableCompanion( + contactId: contactId ?? this.contactId, + isGroupConversation: isGroupConversation ?? this.isGroupConversation, + settingId: settingId ?? this.settingId, + value: value ?? this.value, + createdDate: createdDate ?? this.createdDate, + lastModifiedDate: lastModifiedDate ?? this.lastModifiedDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (contactId.present) { + map['contact_id'] = Variable(contactId.value); + } + if (isGroupConversation.present) { + map['is_group_conversation'] = Variable(isGroupConversation.value); + } + if (settingId.present) { + map['setting_id'] = Variable(settingId.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (createdDate.present) { + map['created_date'] = Variable(createdDate.value); + } + if (lastModifiedDate.present) { + map['last_modified_date'] = Variable(lastModifiedDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ConversationSettingTableCompanion(') + ..write('contactId: $contactId, ') + ..write('isGroupConversation: $isGroupConversation, ') + ..write('settingId: $settingId, ') + ..write('value: $value, ') + ..write('createdDate: $createdDate, ') + ..write('lastModifiedDate: $lastModifiedDate') + ..write(')')) + .toString(); + } +} + +class $LogEntryTableTable extends LogEntryTable + with TableInfo<$LogEntryTableTable, LogEntryTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $LogEntryTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _levelMeta = const VerificationMeta('level'); + @override + late final GeneratedColumnWithTypeConverter level = + GeneratedColumn('level', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true) + .withConverter($LogEntryTableTable.$converterlevel); + static const VerificationMeta _createdDateMeta = + const VerificationMeta('createdDate'); + @override + late final GeneratedColumn createdDate = GeneratedColumn( + 'created_date', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _messageMeta = + const VerificationMeta('message'); + @override + late final GeneratedColumn message = GeneratedColumn( + 'message', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + @override + List get $columns => [id, level, createdDate, message]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'log_entry'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + context.handle(_levelMeta, const VerificationResult.success()); + if (data.containsKey('created_date')) { + context.handle( + _createdDateMeta, + createdDate.isAcceptableOrUnknown( + data['created_date']!, _createdDateMeta)); + } else if (isInserting) { + context.missing(_createdDateMeta); + } + if (data.containsKey('message')) { + context.handle(_messageMeta, + message.isAcceptableOrUnknown(data['message']!, _messageMeta)); + } else if (isInserting) { + context.missing(_messageMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + LogEntryTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LogEntryTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + level: $LogEntryTableTable.$converterlevel.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}level'])!), + createdDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_date'])!, + message: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}message'])!, + ); + } + + @override + $LogEntryTableTable createAlias(String alias) { + return $LogEntryTableTable(attachedDatabase, alias); + } + + static TypeConverter $converterlevel = LogLevelConverter(); + @override + bool get isStrict => true; +} + +class LogEntryTableData extends DataClass + implements Insertable { + final int id; + final LogLevel level; + final DateTime createdDate; + final String message; + const LogEntryTableData( + {required this.id, + required this.level, + required this.createdDate, + required this.message}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + { + map['level'] = + Variable($LogEntryTableTable.$converterlevel.toSql(level)); + } + map['created_date'] = Variable(createdDate); + map['message'] = Variable(message); + return map; + } + + LogEntryTableCompanion toCompanion(bool nullToAbsent) { + return LogEntryTableCompanion( + id: Value(id), + level: Value(level), + createdDate: Value(createdDate), + message: Value(message), + ); + } + + factory LogEntryTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LogEntryTableData( + id: serializer.fromJson(json['id']), + level: serializer.fromJson(json['level']), + createdDate: serializer.fromJson(json['createdDate']), + message: serializer.fromJson(json['message']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'level': serializer.toJson(level), + 'createdDate': serializer.toJson(createdDate), + 'message': serializer.toJson(message), + }; + } + + LogEntryTableData copyWith( + {int? id, LogLevel? level, DateTime? createdDate, String? message}) => + LogEntryTableData( + id: id ?? this.id, + level: level ?? this.level, + createdDate: createdDate ?? this.createdDate, + message: message ?? this.message, + ); + LogEntryTableData copyWithCompanion(LogEntryTableCompanion data) { + return LogEntryTableData( + id: data.id.present ? data.id.value : this.id, + level: data.level.present ? data.level.value : this.level, + createdDate: + data.createdDate.present ? data.createdDate.value : this.createdDate, + message: data.message.present ? data.message.value : this.message, + ); + } + + @override + String toString() { + return (StringBuffer('LogEntryTableData(') + ..write('id: $id, ') + ..write('level: $level, ') + ..write('createdDate: $createdDate, ') + ..write('message: $message') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, level, createdDate, message); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LogEntryTableData && + other.id == this.id && + other.level == this.level && + other.createdDate == this.createdDate && + other.message == this.message); +} + +class LogEntryTableCompanion extends UpdateCompanion { + final Value id; + final Value level; + final Value createdDate; + final Value message; + const LogEntryTableCompanion({ + this.id = const Value.absent(), + this.level = const Value.absent(), + this.createdDate = const Value.absent(), + this.message = const Value.absent(), + }); + LogEntryTableCompanion.insert({ + this.id = const Value.absent(), + required LogLevel level, + required DateTime createdDate, + required String message, + }) : level = Value(level), + createdDate = Value(createdDate), + message = Value(message); + static Insertable custom({ + Expression? id, + Expression? level, + Expression? createdDate, + Expression? message, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (level != null) 'level': level, + if (createdDate != null) 'created_date': createdDate, + if (message != null) 'message': message, + }); + } + + LogEntryTableCompanion copyWith( + {Value? id, + Value? level, + Value? createdDate, + Value? message}) { + return LogEntryTableCompanion( + id: id ?? this.id, + level: level ?? this.level, + createdDate: createdDate ?? this.createdDate, + message: message ?? this.message, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (level.present) { + map['level'] = + Variable($LogEntryTableTable.$converterlevel.toSql(level.value)); + } + if (createdDate.present) { + map['created_date'] = Variable(createdDate.value); + } + if (message.present) { + map['message'] = Variable(message.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LogEntryTableCompanion(') + ..write('id: $id, ') + ..write('level: $level, ') + ..write('createdDate: $createdDate, ') + ..write('message: $message') + ..write(')')) + .toString(); + } +} + +class $UserSettingTableTable extends UserSettingTable + with TableInfo<$UserSettingTableTable, UserSettingTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $UserSettingTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + static const VerificationMeta _valueMeta = const VerificationMeta('value'); + @override + late final GeneratedColumn value = GeneratedColumn( + 'value', aliasedName, false, + type: DriftSqlType.any, requiredDuringInsert: true); + static const VerificationMeta _createdDateMeta = + const VerificationMeta('createdDate'); + @override + late final GeneratedColumn createdDate = GeneratedColumn( + 'created_date', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _lastModifiedDateMeta = + const VerificationMeta('lastModifiedDate'); + @override + late final GeneratedColumn lastModifiedDate = + GeneratedColumn('last_modified_date', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + @override + List get $columns => + [id, value, createdDate, lastModifiedDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_setting'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('value')) { + context.handle( + _valueMeta, value.isAcceptableOrUnknown(data['value']!, _valueMeta)); + } else if (isInserting) { + context.missing(_valueMeta); + } + if (data.containsKey('created_date')) { + context.handle( + _createdDateMeta, + createdDate.isAcceptableOrUnknown( + data['created_date']!, _createdDateMeta)); + } else if (isInserting) { + context.missing(_createdDateMeta); + } + if (data.containsKey('last_modified_date')) { + context.handle( + _lastModifiedDateMeta, + lastModifiedDate.isAcceptableOrUnknown( + data['last_modified_date']!, _lastModifiedDateMeta)); + } else if (isInserting) { + context.missing(_lastModifiedDateMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + UserSettingTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserSettingTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + value: attachedDatabase.typeMapping + .read(DriftSqlType.any, data['${effectivePrefix}value'])!, + createdDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_date'])!, + lastModifiedDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}last_modified_date'])!, + ); + } + + @override + $UserSettingTableTable createAlias(String alias) { + return $UserSettingTableTable(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserSettingTableData extends DataClass + implements Insertable { + final int id; + final DriftAny value; + final DateTime createdDate; + final DateTime lastModifiedDate; + const UserSettingTableData( + {required this.id, + required this.value, + required this.createdDate, + required this.lastModifiedDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['value'] = Variable(value); + map['created_date'] = Variable(createdDate); + map['last_modified_date'] = Variable(lastModifiedDate); + return map; + } + + UserSettingTableCompanion toCompanion(bool nullToAbsent) { + return UserSettingTableCompanion( + id: Value(id), + value: Value(value), + createdDate: Value(createdDate), + lastModifiedDate: Value(lastModifiedDate), + ); + } + + factory UserSettingTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserSettingTableData( + id: serializer.fromJson(json['id']), + value: serializer.fromJson(json['value']), + createdDate: serializer.fromJson(json['createdDate']), + lastModifiedDate: serializer.fromJson(json['lastModifiedDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'value': serializer.toJson(value), + 'createdDate': serializer.toJson(createdDate), + 'lastModifiedDate': serializer.toJson(lastModifiedDate), + }; + } + + UserSettingTableData copyWith( + {int? id, + DriftAny? value, + DateTime? createdDate, + DateTime? lastModifiedDate}) => + UserSettingTableData( + id: id ?? this.id, + value: value ?? this.value, + createdDate: createdDate ?? this.createdDate, + lastModifiedDate: lastModifiedDate ?? this.lastModifiedDate, + ); + UserSettingTableData copyWithCompanion(UserSettingTableCompanion data) { + return UserSettingTableData( + id: data.id.present ? data.id.value : this.id, + value: data.value.present ? data.value.value : this.value, + createdDate: + data.createdDate.present ? data.createdDate.value : this.createdDate, + lastModifiedDate: data.lastModifiedDate.present + ? data.lastModifiedDate.value + : this.lastModifiedDate, + ); + } + + @override + String toString() { + return (StringBuffer('UserSettingTableData(') + ..write('id: $id, ') + ..write('value: $value, ') + ..write('createdDate: $createdDate, ') + ..write('lastModifiedDate: $lastModifiedDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, value, createdDate, lastModifiedDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserSettingTableData && + other.id == this.id && + other.value == this.value && + other.createdDate == this.createdDate && + other.lastModifiedDate == this.lastModifiedDate); +} + +class UserSettingTableCompanion extends UpdateCompanion { + final Value id; + final Value value; + final Value createdDate; + final Value lastModifiedDate; + const UserSettingTableCompanion({ + this.id = const Value.absent(), + this.value = const Value.absent(), + this.createdDate = const Value.absent(), + this.lastModifiedDate = const Value.absent(), + }); + UserSettingTableCompanion.insert({ + required int id, + required DriftAny value, + required DateTime createdDate, + required DateTime lastModifiedDate, + }) : id = Value(id), + value = Value(value), + createdDate = Value(createdDate), + lastModifiedDate = Value(lastModifiedDate); + static Insertable custom({ + Expression? id, + Expression? value, + Expression? createdDate, + Expression? lastModifiedDate, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (value != null) 'value': value, + if (createdDate != null) 'created_date': createdDate, + if (lastModifiedDate != null) 'last_modified_date': lastModifiedDate, + }); + } + + UserSettingTableCompanion copyWith( + {Value? id, + Value? value, + Value? createdDate, + Value? lastModifiedDate}) { + return UserSettingTableCompanion( + id: id ?? this.id, + value: value ?? this.value, + createdDate: createdDate ?? this.createdDate, + lastModifiedDate: lastModifiedDate ?? this.lastModifiedDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (createdDate.present) { + map['created_date'] = Variable(createdDate.value); + } + if (lastModifiedDate.present) { + map['last_modified_date'] = Variable(lastModifiedDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserSettingTableCompanion(') + ..write('id: $id, ') + ..write('value: $value, ') + ..write('createdDate: $createdDate, ') + ..write('lastModifiedDate: $lastModifiedDate') + ..write(')')) + .toString(); + } +} + +abstract class _$UserDatabase extends GeneratedDatabase { + _$UserDatabase(QueryExecutor e) : super(e); + $UserDatabaseManager get managers => $UserDatabaseManager(this); + late final $ConversationSettingTableTable conversationSettingTable = + $ConversationSettingTableTable(this); + late final $LogEntryTableTable logEntryTable = $LogEntryTableTable(this); + late final $UserSettingTableTable userSettingTable = + $UserSettingTableTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => + [conversationSettingTable, logEntryTable, userSettingTable]; +} + +typedef $$ConversationSettingTableTableCreateCompanionBuilder + = ConversationSettingTableCompanion Function({ + required BigInt contactId, + required bool isGroupConversation, + required int settingId, + required DriftAny value, + required DateTime createdDate, + required DateTime lastModifiedDate, +}); +typedef $$ConversationSettingTableTableUpdateCompanionBuilder + = ConversationSettingTableCompanion Function({ + Value contactId, + Value isGroupConversation, + Value settingId, + Value value, + Value createdDate, + Value lastModifiedDate, +}); + +class $$ConversationSettingTableTableFilterComposer + extends Composer<_$UserDatabase, $ConversationSettingTableTable> { + $$ConversationSettingTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get contactId => $composableBuilder( + column: $table.contactId, builder: (column) => ColumnFilters(column)); + + ColumnFilters get isGroupConversation => $composableBuilder( + column: $table.isGroupConversation, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get settingId => $composableBuilder( + column: $table.settingId, builder: (column) => ColumnFilters(column)); + + ColumnFilters get value => $composableBuilder( + column: $table.value, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => ColumnFilters(column)); + + ColumnFilters get lastModifiedDate => $composableBuilder( + column: $table.lastModifiedDate, + builder: (column) => ColumnFilters(column)); +} + +class $$ConversationSettingTableTableOrderingComposer + extends Composer<_$UserDatabase, $ConversationSettingTableTable> { + $$ConversationSettingTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get contactId => $composableBuilder( + column: $table.contactId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get isGroupConversation => $composableBuilder( + column: $table.isGroupConversation, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get settingId => $composableBuilder( + column: $table.settingId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get value => $composableBuilder( + column: $table.value, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get lastModifiedDate => $composableBuilder( + column: $table.lastModifiedDate, + builder: (column) => ColumnOrderings(column)); +} + +class $$ConversationSettingTableTableAnnotationComposer + extends Composer<_$UserDatabase, $ConversationSettingTableTable> { + $$ConversationSettingTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get contactId => + $composableBuilder(column: $table.contactId, builder: (column) => column); + + GeneratedColumn get isGroupConversation => $composableBuilder( + column: $table.isGroupConversation, builder: (column) => column); + + GeneratedColumn get settingId => + $composableBuilder(column: $table.settingId, builder: (column) => column); + + GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); + + GeneratedColumn get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => column); + + GeneratedColumn get lastModifiedDate => $composableBuilder( + column: $table.lastModifiedDate, builder: (column) => column); +} + +class $$ConversationSettingTableTableTableManager extends RootTableManager< + _$UserDatabase, + $ConversationSettingTableTable, + ConversationSettingTableData, + $$ConversationSettingTableTableFilterComposer, + $$ConversationSettingTableTableOrderingComposer, + $$ConversationSettingTableTableAnnotationComposer, + $$ConversationSettingTableTableCreateCompanionBuilder, + $$ConversationSettingTableTableUpdateCompanionBuilder, + ( + ConversationSettingTableData, + BaseReferences<_$UserDatabase, $ConversationSettingTableTable, + ConversationSettingTableData> + ), + ConversationSettingTableData, + PrefetchHooks Function()> { + $$ConversationSettingTableTableTableManager( + _$UserDatabase db, $ConversationSettingTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ConversationSettingTableTableFilterComposer( + $db: db, $table: table), + createOrderingComposer: () => + $$ConversationSettingTableTableOrderingComposer( + $db: db, $table: table), + createComputedFieldComposer: () => + $$ConversationSettingTableTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + Value contactId = const Value.absent(), + Value isGroupConversation = const Value.absent(), + Value settingId = const Value.absent(), + Value value = const Value.absent(), + Value createdDate = const Value.absent(), + Value lastModifiedDate = const Value.absent(), + }) => + ConversationSettingTableCompanion( + contactId: contactId, + isGroupConversation: isGroupConversation, + settingId: settingId, + value: value, + createdDate: createdDate, + lastModifiedDate: lastModifiedDate, + ), + createCompanionCallback: ({ + required BigInt contactId, + required bool isGroupConversation, + required int settingId, + required DriftAny value, + required DateTime createdDate, + required DateTime lastModifiedDate, + }) => + ConversationSettingTableCompanion.insert( + contactId: contactId, + isGroupConversation: isGroupConversation, + settingId: settingId, + value: value, + createdDate: createdDate, + lastModifiedDate: lastModifiedDate, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$ConversationSettingTableTableProcessedTableManager + = ProcessedTableManager< + _$UserDatabase, + $ConversationSettingTableTable, + ConversationSettingTableData, + $$ConversationSettingTableTableFilterComposer, + $$ConversationSettingTableTableOrderingComposer, + $$ConversationSettingTableTableAnnotationComposer, + $$ConversationSettingTableTableCreateCompanionBuilder, + $$ConversationSettingTableTableUpdateCompanionBuilder, + ( + ConversationSettingTableData, + BaseReferences<_$UserDatabase, $ConversationSettingTableTable, + ConversationSettingTableData> + ), + ConversationSettingTableData, + PrefetchHooks Function()>; +typedef $$LogEntryTableTableCreateCompanionBuilder = LogEntryTableCompanion + Function({ + Value id, + required LogLevel level, + required DateTime createdDate, + required String message, +}); +typedef $$LogEntryTableTableUpdateCompanionBuilder = LogEntryTableCompanion + Function({ + Value id, + Value level, + Value createdDate, + Value message, +}); + +class $$LogEntryTableTableFilterComposer + extends Composer<_$UserDatabase, $LogEntryTableTable> { + $$LogEntryTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters get level => + $composableBuilder( + column: $table.level, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnFilters get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => ColumnFilters(column)); + + ColumnFilters get message => $composableBuilder( + column: $table.message, builder: (column) => ColumnFilters(column)); +} + +class $$LogEntryTableTableOrderingComposer + extends Composer<_$UserDatabase, $LogEntryTableTable> { + $$LogEntryTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get level => $composableBuilder( + column: $table.level, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get message => $composableBuilder( + column: $table.message, builder: (column) => ColumnOrderings(column)); +} + +class $$LogEntryTableTableAnnotationComposer + extends Composer<_$UserDatabase, $LogEntryTableTable> { + $$LogEntryTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumnWithTypeConverter get level => + $composableBuilder(column: $table.level, builder: (column) => column); + + GeneratedColumn get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => column); + + GeneratedColumn get message => + $composableBuilder(column: $table.message, builder: (column) => column); +} + +class $$LogEntryTableTableTableManager extends RootTableManager< + _$UserDatabase, + $LogEntryTableTable, + LogEntryTableData, + $$LogEntryTableTableFilterComposer, + $$LogEntryTableTableOrderingComposer, + $$LogEntryTableTableAnnotationComposer, + $$LogEntryTableTableCreateCompanionBuilder, + $$LogEntryTableTableUpdateCompanionBuilder, + ( + LogEntryTableData, + BaseReferences<_$UserDatabase, $LogEntryTableTable, LogEntryTableData> + ), + LogEntryTableData, + PrefetchHooks Function()> { + $$LogEntryTableTableTableManager(_$UserDatabase db, $LogEntryTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$LogEntryTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$LogEntryTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$LogEntryTableTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value level = const Value.absent(), + Value createdDate = const Value.absent(), + Value message = const Value.absent(), + }) => + LogEntryTableCompanion( + id: id, + level: level, + createdDate: createdDate, + message: message, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required LogLevel level, + required DateTime createdDate, + required String message, + }) => + LogEntryTableCompanion.insert( + id: id, + level: level, + createdDate: createdDate, + message: message, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$LogEntryTableTableProcessedTableManager = ProcessedTableManager< + _$UserDatabase, + $LogEntryTableTable, + LogEntryTableData, + $$LogEntryTableTableFilterComposer, + $$LogEntryTableTableOrderingComposer, + $$LogEntryTableTableAnnotationComposer, + $$LogEntryTableTableCreateCompanionBuilder, + $$LogEntryTableTableUpdateCompanionBuilder, + ( + LogEntryTableData, + BaseReferences<_$UserDatabase, $LogEntryTableTable, LogEntryTableData> + ), + LogEntryTableData, + PrefetchHooks Function()>; +typedef $$UserSettingTableTableCreateCompanionBuilder + = UserSettingTableCompanion Function({ + required int id, + required DriftAny value, + required DateTime createdDate, + required DateTime lastModifiedDate, +}); +typedef $$UserSettingTableTableUpdateCompanionBuilder + = UserSettingTableCompanion Function({ + Value id, + Value value, + Value createdDate, + Value lastModifiedDate, +}); + +class $$UserSettingTableTableFilterComposer + extends Composer<_$UserDatabase, $UserSettingTableTable> { + $$UserSettingTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get value => $composableBuilder( + column: $table.value, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => ColumnFilters(column)); + + ColumnFilters get lastModifiedDate => $composableBuilder( + column: $table.lastModifiedDate, + builder: (column) => ColumnFilters(column)); +} + +class $$UserSettingTableTableOrderingComposer + extends Composer<_$UserDatabase, $UserSettingTableTable> { + $$UserSettingTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get value => $composableBuilder( + column: $table.value, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get lastModifiedDate => $composableBuilder( + column: $table.lastModifiedDate, + builder: (column) => ColumnOrderings(column)); +} + +class $$UserSettingTableTableAnnotationComposer + extends Composer<_$UserDatabase, $UserSettingTableTable> { + $$UserSettingTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); + + GeneratedColumn get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => column); + + GeneratedColumn get lastModifiedDate => $composableBuilder( + column: $table.lastModifiedDate, builder: (column) => column); +} + +class $$UserSettingTableTableTableManager extends RootTableManager< + _$UserDatabase, + $UserSettingTableTable, + UserSettingTableData, + $$UserSettingTableTableFilterComposer, + $$UserSettingTableTableOrderingComposer, + $$UserSettingTableTableAnnotationComposer, + $$UserSettingTableTableCreateCompanionBuilder, + $$UserSettingTableTableUpdateCompanionBuilder, + ( + UserSettingTableData, + BaseReferences<_$UserDatabase, $UserSettingTableTable, + UserSettingTableData> + ), + UserSettingTableData, + PrefetchHooks Function()> { + $$UserSettingTableTableTableManager( + _$UserDatabase db, $UserSettingTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$UserSettingTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$UserSettingTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$UserSettingTableTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value value = const Value.absent(), + Value createdDate = const Value.absent(), + Value lastModifiedDate = const Value.absent(), + }) => + UserSettingTableCompanion( + id: id, + value: value, + createdDate: createdDate, + lastModifiedDate: lastModifiedDate, + ), + createCompanionCallback: ({ + required int id, + required DriftAny value, + required DateTime createdDate, + required DateTime lastModifiedDate, + }) => + UserSettingTableCompanion.insert( + id: id, + value: value, + createdDate: createdDate, + lastModifiedDate: lastModifiedDate, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$UserSettingTableTableProcessedTableManager = ProcessedTableManager< + _$UserDatabase, + $UserSettingTableTable, + UserSettingTableData, + $$UserSettingTableTableFilterComposer, + $$UserSettingTableTableOrderingComposer, + $$UserSettingTableTableAnnotationComposer, + $$UserSettingTableTableCreateCompanionBuilder, + $$UserSettingTableTableUpdateCompanionBuilder, + ( + UserSettingTableData, + BaseReferences<_$UserDatabase, $UserSettingTableTable, + UserSettingTableData> + ), + UserSettingTableData, + PrefetchHooks Function()>; + +class $UserDatabaseManager { + final _$UserDatabase _db; + $UserDatabaseManager(this._db); + $$ConversationSettingTableTableTableManager get conversationSettingTable => + $$ConversationSettingTableTableTableManager( + _db, _db.conversationSettingTable); + $$LogEntryTableTableTableManager get logEntryTable => + $$LogEntryTableTableTableManager(_db, _db.logEntryTable); + $$UserSettingTableTableTableManager get userSettingTable => + $$UserSettingTableTableTableManager(_db, _db.userSettingTable); +} diff --git a/turms-chat-demo-flutter/lib/infra/sqlite/user_message_database.dart b/turms-chat-demo-flutter/lib/infra/sqlite/user_message_database.dart new file mode 100644 index 0000000000..8bf97ab150 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/sqlite/user_message_database.dart @@ -0,0 +1,39 @@ +import 'package:drift/drift.dart'; +import 'package:drift_dev/api/migrations.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; + +import '../../domain/message/models/message_type.dart'; +import '../../domain/message/tables/message_table.dart'; +import '../env/env_vars.dart'; +import 'converter/int64_converter.dart'; +import 'converter/uint8_matrix_converter.dart'; +import 'core/database_utils.dart'; + +part 'user_message_database.g.dart'; + +@DriftDatabase(tables: [MessageTable]) +class UserMessageDatabase extends _$UserMessageDatabase { + UserMessageDatabase(super.e); + + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => + MigrationStrategy(beforeOpen: (details) async { + if (kDebugMode) { + await validateDatabaseSchema(); + } + }); +} + +final _userIdToDatabase = {}; + +UserMessageDatabase createUserMessageDatabaseIfNotExists(Int64 userId) => + _userIdToDatabase.putIfAbsent( + userId, + () => UserMessageDatabase(DatabaseUtils.createDatabase( + dbName: 'user_message_${userId.toString()}', + isAppDatabase: false, + logStatements: EnvVars.databaseLogStatements))); diff --git a/turms-chat-demo-flutter/lib/infra/sqlite/user_message_database.g.dart b/turms-chat-demo-flutter/lib/infra/sqlite/user_message_database.g.dart new file mode 100644 index 0000000000..0af85390dd --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/sqlite/user_message_database.g.dart @@ -0,0 +1,711 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_message_database.dart'; + +// ignore_for_file: type=lint +class $MessageTableTable extends MessageTable + with TableInfo<$MessageTableTable, MessageTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $MessageTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumnWithTypeConverter id = + GeneratedColumn('id', aliasedName, false, + type: DriftSqlType.bigInt, requiredDuringInsert: true) + .withConverter($MessageTableTable.$converterid); + static const VerificationMeta _isGroupMessageMeta = + const VerificationMeta('isGroupMessage'); + @override + late final GeneratedColumn isGroupMessage = GeneratedColumn( + 'is_group_message', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_group_message" IN (0, 1))')); + static const VerificationMeta _senderIdMeta = + const VerificationMeta('senderId'); + @override + late final GeneratedColumnWithTypeConverter senderId = + GeneratedColumn('sender_id', aliasedName, false, + type: DriftSqlType.bigInt, requiredDuringInsert: true) + .withConverter($MessageTableTable.$convertersenderId); + static const VerificationMeta _contactIdMeta = + const VerificationMeta('contactId'); + @override + late final GeneratedColumnWithTypeConverter contactId = + GeneratedColumn('contact_id', aliasedName, false, + type: DriftSqlType.bigInt, requiredDuringInsert: true) + .withConverter($MessageTableTable.$convertercontactId); + static const VerificationMeta _txtMeta = const VerificationMeta('txt'); + @override + late final GeneratedColumn txt = GeneratedColumn( + 'text', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _recordsMeta = + const VerificationMeta('records'); + @override + late final GeneratedColumnWithTypeConverter?, Uint8List> + records = GeneratedColumn('records', aliasedName, true, + type: DriftSqlType.blob, requiredDuringInsert: false) + .withConverter?>( + $MessageTableTable.$converterrecordsn); + static const VerificationMeta _typeMeta = const VerificationMeta('type'); + @override + late final GeneratedColumnWithTypeConverter type = + GeneratedColumn('type', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true) + .withConverter($MessageTableTable.$convertertype); + static const VerificationMeta _createdDateMeta = + const VerificationMeta('createdDate'); + @override + late final GeneratedColumn createdDate = GeneratedColumn( + 'created_date', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + @override + List get $columns => [ + id, + isGroupMessage, + senderId, + contactId, + txt, + records, + type, + createdDate + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'message'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + context.handle(_idMeta, const VerificationResult.success()); + if (data.containsKey('is_group_message')) { + context.handle( + _isGroupMessageMeta, + isGroupMessage.isAcceptableOrUnknown( + data['is_group_message']!, _isGroupMessageMeta)); + } else if (isInserting) { + context.missing(_isGroupMessageMeta); + } + context.handle(_senderIdMeta, const VerificationResult.success()); + context.handle(_contactIdMeta, const VerificationResult.success()); + if (data.containsKey('text')) { + context.handle( + _txtMeta, txt.isAcceptableOrUnknown(data['text']!, _txtMeta)); + } + context.handle(_recordsMeta, const VerificationResult.success()); + context.handle(_typeMeta, const VerificationResult.success()); + if (data.containsKey('created_date')) { + context.handle( + _createdDateMeta, + createdDate.isAcceptableOrUnknown( + data['created_date']!, _createdDateMeta)); + } else if (isInserting) { + context.missing(_createdDateMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + MessageTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MessageTableData( + id: $MessageTableTable.$converterid.fromSql(attachedDatabase.typeMapping + .read(DriftSqlType.bigInt, data['${effectivePrefix}id'])!), + isGroupMessage: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_group_message'])!, + senderId: $MessageTableTable.$convertersenderId.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.bigInt, data['${effectivePrefix}sender_id'])!), + contactId: $MessageTableTable.$convertercontactId.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.bigInt, data['${effectivePrefix}contact_id'])!), + txt: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}text']), + records: $MessageTableTable.$converterrecordsn.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.blob, data['${effectivePrefix}records'])), + type: $MessageTableTable.$convertertype.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}type'])!), + createdDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_date'])!, + ); + } + + @override + $MessageTableTable createAlias(String alias) { + return $MessageTableTable(attachedDatabase, alias); + } + + static TypeConverter $converterid = const Int64Converter(); + static TypeConverter $convertersenderId = + const Int64Converter(); + static TypeConverter $convertercontactId = + const Int64Converter(); + static TypeConverter, Uint8List> $converterrecords = + const Uint8MatrixConverter(); + static TypeConverter?, Uint8List?> $converterrecordsn = + NullAwareTypeConverter.wrap($converterrecords); + static JsonTypeConverter2 $convertertype = + const EnumIndexConverter(MessageType.values); + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MessageTableData extends DataClass + implements Insertable { + final Int64 id; + final bool isGroupMessage; + final Int64 senderId; + + /// 1. This can be either a group ID or a user (recipient) ID. + /// 2. We use [contactId] instead of the columns [groupId] and [recipientId], + /// so we don't need to creat two indexes. + final Int64 contactId; + final String? txt; + final List? records; + final MessageType type; + final DateTime createdDate; + const MessageTableData( + {required this.id, + required this.isGroupMessage, + required this.senderId, + required this.contactId, + this.txt, + this.records, + required this.type, + required this.createdDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + { + map['id'] = Variable($MessageTableTable.$converterid.toSql(id)); + } + map['is_group_message'] = Variable(isGroupMessage); + { + map['sender_id'] = Variable( + $MessageTableTable.$convertersenderId.toSql(senderId)); + } + { + map['contact_id'] = Variable( + $MessageTableTable.$convertercontactId.toSql(contactId)); + } + if (!nullToAbsent || txt != null) { + map['text'] = Variable(txt); + } + if (!nullToAbsent || records != null) { + map['records'] = Variable( + $MessageTableTable.$converterrecordsn.toSql(records)); + } + { + map['type'] = + Variable($MessageTableTable.$convertertype.toSql(type)); + } + map['created_date'] = Variable(createdDate); + return map; + } + + MessageTableCompanion toCompanion(bool nullToAbsent) { + return MessageTableCompanion( + id: Value(id), + isGroupMessage: Value(isGroupMessage), + senderId: Value(senderId), + contactId: Value(contactId), + txt: txt == null && nullToAbsent ? const Value.absent() : Value(txt), + records: records == null && nullToAbsent + ? const Value.absent() + : Value(records), + type: Value(type), + createdDate: Value(createdDate), + ); + } + + factory MessageTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MessageTableData( + id: serializer.fromJson(json['id']), + isGroupMessage: serializer.fromJson(json['isGroupMessage']), + senderId: serializer.fromJson(json['senderId']), + contactId: serializer.fromJson(json['contactId']), + txt: serializer.fromJson(json['text']), + records: serializer.fromJson?>(json['records']), + type: $MessageTableTable.$convertertype + .fromJson(serializer.fromJson(json['type'])), + createdDate: serializer.fromJson(json['createdDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'isGroupMessage': serializer.toJson(isGroupMessage), + 'senderId': serializer.toJson(senderId), + 'contactId': serializer.toJson(contactId), + 'text': serializer.toJson(txt), + 'records': serializer.toJson?>(records), + 'type': serializer + .toJson($MessageTableTable.$convertertype.toJson(type)), + 'createdDate': serializer.toJson(createdDate), + }; + } + + MessageTableData copyWith( + {Int64? id, + bool? isGroupMessage, + Int64? senderId, + Int64? contactId, + Value txt = const Value.absent(), + Value?> records = const Value.absent(), + MessageType? type, + DateTime? createdDate}) => + MessageTableData( + id: id ?? this.id, + isGroupMessage: isGroupMessage ?? this.isGroupMessage, + senderId: senderId ?? this.senderId, + contactId: contactId ?? this.contactId, + txt: txt.present ? txt.value : this.txt, + records: records.present ? records.value : this.records, + type: type ?? this.type, + createdDate: createdDate ?? this.createdDate, + ); + MessageTableData copyWithCompanion(MessageTableCompanion data) { + return MessageTableData( + id: data.id.present ? data.id.value : this.id, + isGroupMessage: data.isGroupMessage.present + ? data.isGroupMessage.value + : this.isGroupMessage, + senderId: data.senderId.present ? data.senderId.value : this.senderId, + contactId: data.contactId.present ? data.contactId.value : this.contactId, + txt: data.txt.present ? data.txt.value : this.txt, + records: data.records.present ? data.records.value : this.records, + type: data.type.present ? data.type.value : this.type, + createdDate: + data.createdDate.present ? data.createdDate.value : this.createdDate, + ); + } + + @override + String toString() { + return (StringBuffer('MessageTableData(') + ..write('id: $id, ') + ..write('isGroupMessage: $isGroupMessage, ') + ..write('senderId: $senderId, ') + ..write('contactId: $contactId, ') + ..write('txt: $txt, ') + ..write('records: $records, ') + ..write('type: $type, ') + ..write('createdDate: $createdDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, isGroupMessage, senderId, contactId, txt, records, type, createdDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MessageTableData && + other.id == this.id && + other.isGroupMessage == this.isGroupMessage && + other.senderId == this.senderId && + other.contactId == this.contactId && + other.txt == this.txt && + other.records == this.records && + other.type == this.type && + other.createdDate == this.createdDate); +} + +class MessageTableCompanion extends UpdateCompanion { + final Value id; + final Value isGroupMessage; + final Value senderId; + final Value contactId; + final Value txt; + final Value?> records; + final Value type; + final Value createdDate; + const MessageTableCompanion({ + this.id = const Value.absent(), + this.isGroupMessage = const Value.absent(), + this.senderId = const Value.absent(), + this.contactId = const Value.absent(), + this.txt = const Value.absent(), + this.records = const Value.absent(), + this.type = const Value.absent(), + this.createdDate = const Value.absent(), + }); + MessageTableCompanion.insert({ + required Int64 id, + required bool isGroupMessage, + required Int64 senderId, + required Int64 contactId, + this.txt = const Value.absent(), + this.records = const Value.absent(), + required MessageType type, + required DateTime createdDate, + }) : id = Value(id), + isGroupMessage = Value(isGroupMessage), + senderId = Value(senderId), + contactId = Value(contactId), + type = Value(type), + createdDate = Value(createdDate); + static Insertable custom({ + Expression? id, + Expression? isGroupMessage, + Expression? senderId, + Expression? contactId, + Expression? txt, + Expression? records, + Expression? type, + Expression? createdDate, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (isGroupMessage != null) 'is_group_message': isGroupMessage, + if (senderId != null) 'sender_id': senderId, + if (contactId != null) 'contact_id': contactId, + if (txt != null) 'text': txt, + if (records != null) 'records': records, + if (type != null) 'type': type, + if (createdDate != null) 'created_date': createdDate, + }); + } + + MessageTableCompanion copyWith( + {Value? id, + Value? isGroupMessage, + Value? senderId, + Value? contactId, + Value? txt, + Value?>? records, + Value? type, + Value? createdDate}) { + return MessageTableCompanion( + id: id ?? this.id, + isGroupMessage: isGroupMessage ?? this.isGroupMessage, + senderId: senderId ?? this.senderId, + contactId: contactId ?? this.contactId, + txt: txt ?? this.txt, + records: records ?? this.records, + type: type ?? this.type, + createdDate: createdDate ?? this.createdDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = + Variable($MessageTableTable.$converterid.toSql(id.value)); + } + if (isGroupMessage.present) { + map['is_group_message'] = Variable(isGroupMessage.value); + } + if (senderId.present) { + map['sender_id'] = Variable( + $MessageTableTable.$convertersenderId.toSql(senderId.value)); + } + if (contactId.present) { + map['contact_id'] = Variable( + $MessageTableTable.$convertercontactId.toSql(contactId.value)); + } + if (txt.present) { + map['text'] = Variable(txt.value); + } + if (records.present) { + map['records'] = Variable( + $MessageTableTable.$converterrecordsn.toSql(records.value)); + } + if (type.present) { + map['type'] = + Variable($MessageTableTable.$convertertype.toSql(type.value)); + } + if (createdDate.present) { + map['created_date'] = Variable(createdDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MessageTableCompanion(') + ..write('id: $id, ') + ..write('isGroupMessage: $isGroupMessage, ') + ..write('senderId: $senderId, ') + ..write('contactId: $contactId, ') + ..write('txt: $txt, ') + ..write('records: $records, ') + ..write('type: $type, ') + ..write('createdDate: $createdDate') + ..write(')')) + .toString(); + } +} + +abstract class _$UserMessageDatabase extends GeneratedDatabase { + _$UserMessageDatabase(QueryExecutor e) : super(e); + $UserMessageDatabaseManager get managers => $UserMessageDatabaseManager(this); + late final $MessageTableTable messageTable = $MessageTableTable(this); + late final Index senderId = + Index('sender_id', 'CREATE INDEX sender_id ON message (sender_id)'); + late final Index contactId = + Index('contact_id', 'CREATE INDEX contact_id ON message (contact_id)'); + late final Index createdDate = Index( + 'created_date', 'CREATE INDEX created_date ON message (created_date)'); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => + [messageTable, senderId, contactId, createdDate]; +} + +typedef $$MessageTableTableCreateCompanionBuilder = MessageTableCompanion + Function({ + required Int64 id, + required bool isGroupMessage, + required Int64 senderId, + required Int64 contactId, + Value txt, + Value?> records, + required MessageType type, + required DateTime createdDate, +}); +typedef $$MessageTableTableUpdateCompanionBuilder = MessageTableCompanion + Function({ + Value id, + Value isGroupMessage, + Value senderId, + Value contactId, + Value txt, + Value?> records, + Value type, + Value createdDate, +}); + +class $$MessageTableTableFilterComposer + extends Composer<_$UserMessageDatabase, $MessageTableTable> { + $$MessageTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnWithTypeConverterFilters get id => + $composableBuilder( + column: $table.id, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnFilters get isGroupMessage => $composableBuilder( + column: $table.isGroupMessage, + builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters get senderId => + $composableBuilder( + column: $table.senderId, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnWithTypeConverterFilters get contactId => + $composableBuilder( + column: $table.contactId, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnFilters get txt => $composableBuilder( + column: $table.txt, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters?, List, Uint8List> + get records => $composableBuilder( + column: $table.records, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnWithTypeConverterFilters get type => + $composableBuilder( + column: $table.type, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnFilters get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => ColumnFilters(column)); +} + +class $$MessageTableTableOrderingComposer + extends Composer<_$UserMessageDatabase, $MessageTableTable> { + $$MessageTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get isGroupMessage => $composableBuilder( + column: $table.isGroupMessage, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get senderId => $composableBuilder( + column: $table.senderId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get contactId => $composableBuilder( + column: $table.contactId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get txt => $composableBuilder( + column: $table.txt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get records => $composableBuilder( + column: $table.records, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get type => $composableBuilder( + column: $table.type, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => ColumnOrderings(column)); +} + +class $$MessageTableTableAnnotationComposer + extends Composer<_$UserMessageDatabase, $MessageTableTable> { + $$MessageTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumnWithTypeConverter get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get isGroupMessage => $composableBuilder( + column: $table.isGroupMessage, builder: (column) => column); + + GeneratedColumnWithTypeConverter get senderId => + $composableBuilder(column: $table.senderId, builder: (column) => column); + + GeneratedColumnWithTypeConverter get contactId => + $composableBuilder(column: $table.contactId, builder: (column) => column); + + GeneratedColumn get txt => + $composableBuilder(column: $table.txt, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, Uint8List> get records => + $composableBuilder(column: $table.records, builder: (column) => column); + + GeneratedColumnWithTypeConverter get type => + $composableBuilder(column: $table.type, builder: (column) => column); + + GeneratedColumn get createdDate => $composableBuilder( + column: $table.createdDate, builder: (column) => column); +} + +class $$MessageTableTableTableManager extends RootTableManager< + _$UserMessageDatabase, + $MessageTableTable, + MessageTableData, + $$MessageTableTableFilterComposer, + $$MessageTableTableOrderingComposer, + $$MessageTableTableAnnotationComposer, + $$MessageTableTableCreateCompanionBuilder, + $$MessageTableTableUpdateCompanionBuilder, + ( + MessageTableData, + BaseReferences<_$UserMessageDatabase, $MessageTableTable, + MessageTableData> + ), + MessageTableData, + PrefetchHooks Function()> { + $$MessageTableTableTableManager( + _$UserMessageDatabase db, $MessageTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$MessageTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$MessageTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$MessageTableTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value isGroupMessage = const Value.absent(), + Value senderId = const Value.absent(), + Value contactId = const Value.absent(), + Value txt = const Value.absent(), + Value?> records = const Value.absent(), + Value type = const Value.absent(), + Value createdDate = const Value.absent(), + }) => + MessageTableCompanion( + id: id, + isGroupMessage: isGroupMessage, + senderId: senderId, + contactId: contactId, + txt: txt, + records: records, + type: type, + createdDate: createdDate, + ), + createCompanionCallback: ({ + required Int64 id, + required bool isGroupMessage, + required Int64 senderId, + required Int64 contactId, + Value txt = const Value.absent(), + Value?> records = const Value.absent(), + required MessageType type, + required DateTime createdDate, + }) => + MessageTableCompanion.insert( + id: id, + isGroupMessage: isGroupMessage, + senderId: senderId, + contactId: contactId, + txt: txt, + records: records, + type: type, + createdDate: createdDate, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$MessageTableTableProcessedTableManager = ProcessedTableManager< + _$UserMessageDatabase, + $MessageTableTable, + MessageTableData, + $$MessageTableTableFilterComposer, + $$MessageTableTableOrderingComposer, + $$MessageTableTableAnnotationComposer, + $$MessageTableTableCreateCompanionBuilder, + $$MessageTableTableUpdateCompanionBuilder, + ( + MessageTableData, + BaseReferences<_$UserMessageDatabase, $MessageTableTable, + MessageTableData> + ), + MessageTableData, + PrefetchHooks Function()>; + +class $UserMessageDatabaseManager { + final _$UserMessageDatabase _db; + $UserMessageDatabaseManager(this._db); + $$MessageTableTableTableManager get messageTable => + $$MessageTableTableTableManager(_db, _db.messageTable); +} diff --git a/turms-chat-demo-flutter/lib/infra/storage/secure_storage_utils.dart b/turms-chat-demo-flutter/lib/infra/storage/secure_storage_utils.dart new file mode 100644 index 0000000000..e2e88246f7 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/storage/secure_storage_utils.dart @@ -0,0 +1,21 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class SecureStorageUtils { + SecureStorageUtils._(); + + static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock)); + + static Future setString(String key, String value) async => + _secureStorage.write(key: key, value: value); + + static Future setBool(String key, bool value) async => + _secureStorage.write(key: key, value: value.toString()); + + static Future setNum(String key, num value) async => + _secureStorage.write(key: key, value: value.toString()); + + static Future> getKeyToValue() async => + _secureStorage.readAll(); +} diff --git a/turms-chat-demo-flutter/lib/infra/storage/storage_setting_names.dart b/turms-chat-demo-flutter/lib/infra/storage/storage_setting_names.dart new file mode 100644 index 0000000000..9598a49f30 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/storage/storage_setting_names.dart @@ -0,0 +1,6 @@ +class StorageSettingNames { + StorageSettingNames._(); + + static const settingUserId = 'user.id'; + static const settingUserPassword = 'user.password'; +} diff --git a/turms-chat-demo-flutter/lib/infra/task/debouncer.dart b/turms-chat-demo-flutter/lib/infra/task/debouncer.dart new file mode 100644 index 0000000000..b372ed55a8 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/task/debouncer.dart @@ -0,0 +1,18 @@ +import 'dart:async'; +import 'dart:ui'; + +class Debouncer { + Debouncer({required this.timeout}); + + final Duration timeout; + Timer? _timer; + + void run(VoidCallback callback) { + _timer?.cancel(); + _timer = Timer(timeout, callback); + } + + void cancel() { + _timer?.cancel(); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/task/task_utils.dart b/turms-chat-demo-flutter/lib/infra/task/task_utils.dart new file mode 100644 index 0000000000..2af2985b14 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/task/task_utils.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +typedef Callback = Future Function(); + +class TaskUtils { + TaskUtils._(); + + static final _idToCallback = >{}; + static final _idToTimer = {}; + + /// Use [Future] to eliminate unnecessary closure context: + /// https://github.com/dart-lang/sdk/issues/36983 + static Future cacheFuture( + {required Object id, required Future future}) { + final result = _idToCallback[id]; + if (result != null) { + return result as Future; + } + _idToCallback[id] = future; + return future.whenComplete(() => _idToCallback.remove(id)); + } + + static Future cacheFutureProvider( + {required Object id, required Future Function() futureProvider}) { + final result = _idToCallback[id]; + if (result != null) { + return result as Future; + } + final future = futureProvider.call(); + _idToCallback[id] = future; + return future.whenComplete(() => _idToCallback.remove(id)); + } + + static Future addTask( + {required Object id, required Future Function() callback}) { + final result = _idToCallback[id]; + if (result != null) { + return result as Future; + } + final value = callback(); + _idToCallback[id] = value; + return value.whenComplete(() => _idToCallback.remove(id)); + } + + static Future addPeriodicTask( + {required String id, + required Duration duration, + required Callback callback, + bool runImmediately = false}) async { + final timer = _idToTimer[id]; + if (timer != null) { + return false; + } + if (runImmediately) { + if (!await callback()) { + return true; + } + } + _idToTimer[id] = Timer.periodic(duration, (timer) async { + if (!await callback()) { + timer.cancel(); + } + _idToTimer.remove(id); + }); + return true; + } + + static void removeTask(String id) { + _idToTimer.remove(id)?.cancel(); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/time/datetime_utils.dart b/turms-chat-demo-flutter/lib/infra/time/datetime_utils.dart new file mode 100644 index 0000000000..4189ee0a7a --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/time/datetime_utils.dart @@ -0,0 +1,13 @@ +class DateTimeUtils { + DateTimeUtils._(); + + static DateTime getFirstDateOfTheWeek(DateTime dateTime) => + dateTime.subtract(Duration(days: dateTime.weekday - 1)); + + static DateTime getMostRecentWeekday(DateTime date, int weekday) => + DateTime(date.year, date.month, date.day - (date.weekday - weekday) % 7); + + static bool isBetween(DateTime date, DateTime? start, DateTime? end) => + (start != null && date.isAfter(start)) && + (end != null && date.isBefore(end)); +} diff --git a/turms-chat-demo-flutter/lib/infra/tray/tray_utils.dart b/turms-chat-demo-flutter/lib/infra/tray/tray_utils.dart new file mode 100644 index 0000000000..c5837d1afd --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/tray/tray_utils.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; +import 'package:tray_manager/tray_manager.dart'; + +import '../platform/platform_utils.dart'; +import '../window/window_utils.dart'; + +final class TrayUtils { + TrayUtils._(); + + static bool get supportsTray => PlatformUtils.isAnyTargetPlatform( + [TargetPlatform.linux, TargetPlatform.macOS, TargetPlatform.windows]); + + static Future initTray( + String tooltip, String icon, List menuItems) async { + if (!supportsTray) { + return; + } + await trayManager.setIcon(icon); + await trayManager.setContextMenu(Menu( + items: menuItems + .map((item) => MenuItem( + key: item.key, + label: item.label, + )) + .toList())); + await trayManager.setToolTip(tooltip); + final keyToOnTap = {}; + for (final item in menuItems) { + keyToOnTap[item.key] = item.onTap; + } + trayManager.addListener(_TrayListener(onTrayMenuItemTap: (item) { + keyToOnTap[item.key]!.call(); + })); + } +} + +class TrayMenuItem { + const TrayMenuItem( + {required this.key, required this.label, required this.onTap}); + + final String key; + final String label; + final void Function() onTap; +} + +class _TrayListener extends TrayListener { + _TrayListener({required this.onTrayMenuItemTap}); + + final void Function(MenuItem menuItem) onTrayMenuItemTap; + + @override + Future onTrayIconMouseDown() async { + await WindowUtils.show(); + } + + @override + Future onTrayIconRightMouseDown() async => + trayManager.popUpContextMenu(); + + @override + void onTrayMenuItemClick(MenuItem menuItem) => onTrayMenuItemTap(menuItem); +} diff --git a/turms-chat-demo-flutter/lib/infra/ui/color_extensions.dart b/turms-chat-demo-flutter/lib/infra/ui/color_extensions.dart new file mode 100644 index 0000000000..1fe13f1722 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/ui/color_extensions.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +extension ColorBrightness on Color { + Color darken([double factor = .1]) => Color.lerp(this, Colors.black, factor)!; + + Color lighten([double factor = .1]) => + Color.lerp(this, Colors.white, factor)!; + + bool isLight() => computeLuminance() > 0.5; + + bool isDark() => computeLuminance() <= 0.5; +} diff --git a/turms-chat-demo-flutter/lib/infra/ui/scroll_utils.dart b/turms-chat-demo-flutter/lib/infra/ui/scroll_utils.dart new file mode 100644 index 0000000000..1d21dd1de9 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/ui/scroll_utils.dart @@ -0,0 +1,68 @@ +import 'dart:math'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +class ScrollUtils { + ScrollUtils._(); + + static void ensureRenderBoxVisible({required RenderBox renderBox}) { + final viewport = + RenderAbstractViewport.maybeOf(renderBox) as RenderViewportBase?; + if (viewport == null) { + return; + } + final leadingEdgeOffset = viewport.getOffsetToReveal( + renderBox, + 0.0, + ); + final trailingEdgeOffset = viewport.getOffsetToReveal( + renderBox, + 1.0, + ); + final currentOffset = viewport.offset.pixels; + final targetOffset = RevealedOffset.clampOffset( + leadingEdgeOffset: leadingEdgeOffset, + trailingEdgeOffset: trailingEdgeOffset, + currentOffset: currentOffset, + ); + if (targetOffset == null) { + // return if already fully visible + return; + } + viewport.offset.jumpTo(targetOffset.offset); + } + + static void ensureVisible( + {required ScrollController controller, + double? viewportDimension, + required double itemOffset, + required double itemHeight}) { + viewportDimension ??= controller.position.viewportDimension; + if (checkIfOffsetInViewport( + controller, viewportDimension, itemOffset, itemHeight)) { + return; + } + final moveOffset1 = controller.offset - itemOffset; + if (moveOffset1 < 0) { + controller.jumpTo(min(controller.position.maxScrollExtent, + itemOffset + itemHeight - viewportDimension)); + } else { + final moveOffset2 = + itemOffset + itemHeight - controller.offset - viewportDimension; + if (moveOffset2 < 0 || moveOffset1 < moveOffset2) { + controller.jumpTo(max(controller.position.minScrollExtent, itemOffset)); + } else { + controller.jumpTo(min(controller.position.maxScrollExtent, + itemOffset + itemHeight - viewportDimension)); + } + } + } + + static bool checkIfOffsetInViewport(ScrollController controller, + double viewportDimension, double itemOffset, double itemHeight) { + final offset = controller.offset; + return offset <= itemOffset && + (itemOffset + itemHeight) <= (offset + viewportDimension); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/ui/size_utils.dart b/turms-chat-demo-flutter/lib/infra/ui/size_utils.dart new file mode 100644 index 0000000000..f67e68a72f --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/ui/size_utils.dart @@ -0,0 +1,17 @@ +import 'dart:ui'; + +class SizeUtils { + SizeUtils._(); + + static Size keepAspectRatio(Size size, double maxWidth, double maxHeight) { + final width = size.width; + final height = size.height; + if (width > maxWidth || height > maxHeight) { + final ratio = width / height; + return ratio > 1 + ? Size(maxWidth, (maxWidth / ratio).roundToDouble()) + : Size((maxHeight * ratio).roundToDouble(), maxHeight); + } + return size; + } +} diff --git a/turms-chat-demo-flutter/lib/infra/ui/text_utils.dart b/turms-chat-demo-flutter/lib/infra/ui/text_utils.dart new file mode 100644 index 0000000000..e87b31b1d1 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/ui/text_utils.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../built_in_types/built_in_type_helpers.dart'; + +class TextUtils { + TextUtils._(); + + static bool shouldHighlightText({ + required String text, + required String searchText, + }) { + if (searchText.isBlank) { + return false; + } + return text.toLowerCase().contains(searchText.toLowerCase()); + } + + static List highlightSearchText({ + required String text, + TextStyle? textStyle, + required String searchText, + TextStyle? searchTextStyle, + }) { + if (text.isBlank || searchText.isBlank) { + return [TextSpan(text: text, style: textStyle)]; + } + final textSpans = []; + final textToMatch = text.toLowerCase(); + searchText = searchText.toLowerCase(); + searchTextStyle ??= textStyle; + var index = 0; + while (true) { + final searchTextIndex = textToMatch.indexOf(searchText, index); + if (searchTextIndex == -1) { + if (index == 0) { + return [TextSpan(text: text, style: textStyle)]; + } + textSpans.add(TextSpan(text: text.substring(index), style: textStyle)); + break; + } + if (index != searchTextIndex) { + textSpans.add(TextSpan( + text: text.substring(index, searchTextIndex), style: textStyle)); + } + textSpans.add(TextSpan( + text: text.substring( + searchTextIndex, searchTextIndex + searchText.length), + style: searchTextStyle)); + index = searchTextIndex + searchText.length; + } + return textSpans; + } +} diff --git a/turms-chat-demo-flutter/lib/infra/units/file_size_extensions.dart b/turms-chat-demo-flutter/lib/infra/units/file_size_extensions.dart new file mode 100644 index 0000000000..701cec7c3a --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/units/file_size_extensions.dart @@ -0,0 +1,43 @@ +const affixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; +const divider = 1024; + +extension FileSizeExtensions on num { + String toHumanReadableFileSize({int round = 2}) { + final size = this; + var runningDivider = divider; + var runningPreviousDivider = 0; + var affix = 0; + + while (size >= runningDivider && affix < affixes.length - 1) { + runningPreviousDivider = runningDivider; + runningDivider *= divider; + affix++; + } + + var result = + (runningPreviousDivider == 0 ? size : size / runningPreviousDivider) + .toStringAsFixed(round); + + if (result.endsWith('0' * round)) { + result = result.substring(0, result.length - round - 1); + } + + return '$result ${affixes[affix]}'; + } +} + +extension FileSizeExtensionsInt on int { + // ignore: non_constant_identifier_names + int get KB => this * 1024; + + // ignore: non_constant_identifier_names + int get MB => this * 1024 * 1024; +} + +extension FileSizeExtensionsBigInt on BigInt { + // ignore: non_constant_identifier_names + BigInt get KB => this * BigInt.from(1024); + + // ignore: non_constant_identifier_names + BigInt get MB => this * BigInt.from(1024 * 1024); +} diff --git a/turms-chat-demo-flutter/lib/infra/units/math_extensions.dart b/turms-chat-demo-flutter/lib/infra/units/math_extensions.dart new file mode 100644 index 0000000000..4919d6d25a --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/units/math_extensions.dart @@ -0,0 +1,7 @@ +import 'dart:math'; + +const _degreesToRadians = pi / 180; + +extension IntMathExtension on int { + double degreesToRadians() => _degreesToRadians * this; +} diff --git a/turms-chat-demo-flutter/lib/infra/window/window_event_listener.dart b/turms-chat-demo-flutter/lib/infra/window/window_event_listener.dart new file mode 100644 index 0000000000..cf4301a6d1 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/window/window_event_listener.dart @@ -0,0 +1,31 @@ +import 'package:window_manager/window_manager.dart'; + +class WindowEventListener extends WindowListener { + WindowEventListener( + {this.onClose, this.onFocus, this.onMaximize, this.onUnmaximize}); + + final void Function()? onClose; + final void Function()? onFocus; + final void Function()? onMaximize; + final void Function()? onUnmaximize; + + @override + void onWindowClose() { + onClose?.call(); + } + + @override + void onWindowFocus() { + onFocus?.call(); + } + + @override + void onWindowMaximize() { + onMaximize?.call(); + } + + @override + void onWindowUnmaximize() { + onUnmaximize?.call(); + } +} diff --git a/turms-chat-demo-flutter/lib/infra/window/window_utils.dart b/turms-chat-demo-flutter/lib/infra/window/window_utils.dart new file mode 100644 index 0000000000..4cfe91c075 --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/window/window_utils.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:window_manager/window_manager.dart'; + +import 'window_event_listener.dart'; + +class WindowUtils { + WindowUtils._(); + + static Future maximize() => windowManager.maximize(); + + static Future unmaximize() => windowManager.unmaximize(); + + static Future isMaximized() => windowManager.isMaximized(); + + static Future minimize() => windowManager.minimize(); + + static Future isAlwaysOnTop() => windowManager.isAlwaysOnTop(); + + static Future setAlwaysOnTop(bool isAlwaysOnTop) => + windowManager.setAlwaysOnTop(isAlwaysOnTop); + + static Future isVisible() => windowManager.isVisible(); + + static Future show({bool focus = true}) async { + await windowManager.show(); + if (focus) { + await windowManager.focus(); + } + } + + static Future hide() => windowManager.hide(); + + static Future focus() async => windowManager.focus(); + + static Future ensureInitialized() => windowManager.ensureInitialized(); + + static Future setupWindow( + {Size? size, + Size? minimumSize, + bool resizable = false, + Color? backgroundColor, + String? title}) async { + await windowManager.waitUntilReadyToShow(WindowOptions( + minimumSize: minimumSize, + size: size, + backgroundColor: Colors.transparent, + center: true, + titleBarStyle: TitleBarStyle.hidden, + skipTaskbar: false, + title: title)); + if (backgroundColor != null) { + await windowManager.setBackgroundColor(backgroundColor); + } + // Note that we should not make the window frameless, resizable will not work + // on Windows otherwise. + // await windowManager.setAsFrameless(); + await windowManager.setResizable(resizable); + await windowManager.setHasShadow(true); + } + + static Future startDragging() => windowManager.startDragging(); + + static bool hasListeners() => windowManager.hasListeners; + + static void addListener(WindowListener listener) { + windowManager.addListener(listener); + } + + static void removeListener(WindowEventListener listener) { + windowManager.removeListener(listener); + } + + static Future setSkipTaskbar(bool skip) async => + windowManager.setSkipTaskbar(skip); + + static Future waitUntilInvisible() async { + while (await WindowUtils.isVisible()) { + await Future.delayed(const Duration(milliseconds: 50)); + } + } +} + +final isWindowMaximizedViewModel = StateProvider((ref) => false); diff --git a/turms-chat-demo-flutter/lib/infra/worker/worker_manager.dart b/turms-chat-demo-flutter/lib/infra/worker/worker_manager.dart new file mode 100644 index 0000000000..fbc82250ca --- /dev/null +++ b/turms-chat-demo-flutter/lib/infra/worker/worker_manager.dart @@ -0,0 +1,9 @@ +import 'package:flutter/foundation.dart'; + +class WorkerManager { + WorkerManager._(); + + static Future schedule(ComputeCallback callback, M message) => + // TODO: migrate to use isolate_manager + compute(callback, message); +} diff --git a/turms-chat-demo-flutter/lib/main.dart b/turms-chat-demo-flutter/lib/main.dart new file mode 100644 index 0000000000..28063eabda --- /dev/null +++ b/turms-chat-demo-flutter/lib/main.dart @@ -0,0 +1,162 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_platform_alert/flutter_platform_alert.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +import 'domain/app/models/app_settings.dart'; +import 'domain/app/repositories/app_setting_repository.dart'; +import 'domain/app/view_models/app_settings_view_model.dart'; +import 'domain/user/repositories/user_login_info_repository.dart'; +import 'domain/user/view_models/user_login_infos_view_model.dart'; +import 'infra/app/app_config.dart'; +import 'infra/app/app_utils.dart'; +import 'infra/assets/assets.gen.dart'; +import 'infra/autostart/autostart_manager.dart'; +import 'infra/env/env_vars.dart'; +import 'infra/exception/exception_extensions.dart'; +import 'infra/logging/logger.dart'; +import 'infra/media/video_utils.dart'; +import 'infra/platform/platform_helpers.dart'; +import 'infra/rpc/rpc_client.dart'; +import 'infra/rpc/rpc_server.dart'; +import 'infra/rust/frb_generated.dart'; +import 'infra/tray/tray_utils.dart'; +import 'infra/window/window_event_listener.dart'; +import 'infra/window/window_utils.dart'; +import 'ui/desktop/pages/app.dart'; + +Future main(List args) async { + try { + await _run(args); + } catch (e, s) { + logger.error(e.toString(), e, s); + // TODO: i10n + final text = e is Exception ? e.message : e.toString(); + await FlutterPlatformAlert.showAlert( + windowTitle: 'Could not launch "${EnvVars.windowTitle}"', + text: text, + iconStyle: IconStyle.error, + options: PlatformAlertOptions( + windows: WindowsAlertOptions(preferMessageBox: true))); + // Ensure all logs are flushed before exit. + scheduleMicrotask(() { + exit(1); + }); + } +} + +Future _run(List args) async { + // TODO: Enable other platforms when we have adapted and tested. + if (!isDesktop) { + throw Exception('Only desktop is supported currently'); + } + WidgetsFlutterBinding.ensureInitialized(); + MaterialSymbolsBase.forceCompileTimeTreeShaking(); + + FlutterError.onError = (FlutterErrorDetails details) { + logger.error('Caught a Flutter error: $details'); + }; + + await WindowUtils.ensureInitialized(); + await _runAsMainApp(args); +} + +Future _runAsMainApp(List args) async { + if (kReleaseMode) { + // logger.addAppender((event) { + // if (event.level.value <= Level.error.value) { + // // TODO: Send error logs to servers + // } + // }); + } else if (kDebugMode) { + debugInvertOversizedImages = true; + } + + logger.warn( + 'The application is in the alpha stage and under active development. ' + 'There are still many known problems and unfinished features, you do NOT need report them. ' + 'And we will make incompatible changes without migration support, such as database schema changes'); + + await AppConfig.load(); + await RustLib.init(); + logger + ..info( + 'The directory info: the application directory: ${AppConfig.appDir}, the temporary directory: ${AppConfig.tempDir}') + ..info('The application package info: ${AppConfig.packageInfo}'); + + VideoUtils.ensureInitialized(); + + final container = ProviderContainer(); + await _initForDesktopPlatforms(args, container); + await _loadAppSettings(container); + + runApp(UncontrolledProviderScope( + container: container, child: App(container: container))); +} + +Future _loadAppSettings(ProviderContainer container) async { + final appSettings = await appSettingRepository.selectAll(); + container.read(appSettingsViewModel.notifier).state = + AppSettings.fromTableData(appSettings); + + final userLoginInfos = await userLoginInfoRepository.selectUserLoginInfos(); + container.read(userLoginInfosViewModel.notifier).state = userLoginInfos; +} + +Future _initForDesktopPlatforms( + List args, ProviderContainer container) async { + if (args.contains('daemon')) { + await RpcClient.connect(); + } else { + final rpcServer = await RpcServer.create(); + WidgetsBinding.instance + .addObserver(AppLifecycleListener(onExitRequested: () async { + // TODO: In fact, the callback will never be called + // as the reason mentioned in [AppUtils.close]. + try { + await rpcServer.close(); + } catch (e) { + // ignore + } + return AppExitResponse.exit; + })); + } + + initAutostartManager( + appName: AppConfig.packageInfo.appName, + appPath: Platform.resolvedExecutable, + args: []); + + // UI-related + + WindowUtils.addListener(WindowEventListener(onMaximize: () { + container.read(isWindowMaximizedViewModel.notifier).state = true; + }, onUnmaximize: () { + container.read(isWindowMaximizedViewModel.notifier).state = false; + })); + + // Note that we need to setup window before painting, + // otherwise UI will jitter. + await WindowUtils.setupWindow( + minimumSize: AppConfig.defaultWindowSizeForLoginScreen, + size: AppConfig.defaultWindowSizeForLoginScreen, + backgroundColor: Colors.transparent, + title: AppConfig.title); + try { + await TrayUtils.initTray( + AppConfig.title, + // TODO: use ico to display + Assets.images.iconPng.path, + [ + // TODO: i10n + const TrayMenuItem(key: 'exit', label: 'Exit', onTap: AppUtils.close), + ]); + } catch (e) { + logger.warn('Failed to init the tray: $e'); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/client.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/client.dart new file mode 100644 index 0000000000..04c835d78a --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/client.dart @@ -0,0 +1,143 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart'; + +import '../../../../../domain/user/view_models/logged_in_user_info_view_model.dart'; +import '../../../../../infra/env/env_vars.dart'; +import 'models/gif.dart'; +import 'models/languages.dart'; +import 'models/rating.dart'; +import 'models/response.dart'; +import 'models/type.dart'; + +/// Reference: https://developers.giphy.com/docs/api/endpoint +class GiphyClient { + GiphyClient({required String apiKey, required this.randomId}) + : _apiKey = apiKey; + + static final baseUri = Uri(scheme: 'https', host: 'api.giphy.com'); + static const String _apiVersion = 'v1'; + static final Client _client = Client(); + + final String _apiKey; + final String randomId; + + Future trending({ + int offset = 0, + int limit = 30, + String rating = GiphyRating.g, + String lang = GiphyLanguage.english, + GiphyType type = GiphyType.stickers, + }) async => + _fetchCollection( + baseUri.replace( + path: '$_apiVersion/${type.name}/trending', + queryParameters: { + 'offset': '$offset', + 'limit': '$limit', + 'rating': rating, + 'lang': lang + }, + ), + ); + + Future search( + String query, { + int offset = 0, + int limit = 30, + String rating = GiphyRating.g, + String lang = GiphyLanguage.english, + GiphyType type = GiphyType.stickers, + }) async => + _fetchCollection( + baseUri.replace( + path: '$_apiVersion/${type.name}/search', + queryParameters: { + 'q': query, + 'offset': '$offset', + 'limit': '$limit', + 'rating': rating, + 'lang': lang, + }, + ), + ); + + Future emojis({ + int offset = 0, + int limit = 30, + String rating = GiphyRating.g, + String lang = GiphyLanguage.english, + }) async => + _fetchCollection( + baseUri.replace( + path: '$_apiVersion/${GiphyType.emoji.name}', + queryParameters: { + 'offset': '$offset', + 'limit': '$limit', + 'rating': rating, + 'lang': lang, + }, + ), + ); + + Future random({ + required String tag, + String rating = GiphyRating.g, + GiphyType type = GiphyType.stickers, + }) async => + _fetchGif( + baseUri.replace( + path: '$_apiVersion/${type.name}/random', + queryParameters: { + 'tag': tag, + 'rating': rating, + }, + ), + ); + + Future byId(String id) async => + _fetchGif(baseUri.replace(path: 'v1/gifs/$id')); + + Future getRandomId() async => + _getRandomId(baseUri.replace(path: 'v1/randomid')); + + Future _fetchGif(Uri uri) async { + final response = await _getWithAuthorization(uri); + final body = json.decode(response.body) as Map; + return GiphyGif.fromJson(body['data'] as Map); + } + + Future _fetchCollection(Uri uri) async { + final response = await _getWithAuthorization(uri); + final body = json.decode(response.body) as Map; + return GiphyResponse.fromJson(body); + } + + Future _getRandomId(Uri uri) async { + final response = await _getWithAuthorization(uri); + final body = json.decode(response.body) as Map; + final data = body['data'] as Map; + return data['random_id'] as String; + } + + Future _getWithAuthorization(Uri uri) async { + final response = await _client.get(uri.replace( + queryParameters: Map.from(uri.queryParameters) + ..putIfAbsent('api_key', () => _apiKey) + ..putIfAbsent('random_id', () => randomId))); + if (response.statusCode == 200) { + return response; + } + throw Exception( + 'Code: ${response.statusCode}. Response body: ${response.body}'); + } +} + +const _apiKey = EnvVars.giphyApiKey; + +final giphyClientProvider = StateProvider((ref) { + final randomId = ref.watch(loggedInUserViewModel)!.userId.toString() ?? ''; + return GiphyClient(apiKey: _apiKey, randomId: randomId); +}); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/gif.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/gif.dart new file mode 100644 index 0000000000..f485f82705 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/gif.dart @@ -0,0 +1,139 @@ +import 'images.dart'; + +class GiphyGif { + GiphyGif({ + required this.title, + required this.type, + required this.id, + required this.slug, + required this.url, + required this.bitlyGifUrl, + required this.bitlyUrl, + required this.embedUrl, + required this.username, + required this.source, + required this.rating, + required this.contentUrl, + required this.sourceTld, + required this.sourcePostUrl, + required this.isSticker, + required this.importDatetime, + required this.trendingDatetime, + required this.images, + }); + + factory GiphyGif.fromJson(Map json) => GiphyGif( + title: json['title'] as String?, + type: json['type'] as String?, + id: json['id'] as String?, + slug: json['slug'] as String?, + url: json['url'] as String?, + bitlyGifUrl: json['bitly_gif_url'] as String?, + bitlyUrl: json['bitly_url'] as String?, + embedUrl: json['embed_url'] as String?, + username: json['username'] as String?, + source: json['source'] as String?, + rating: json['rating'] as String?, + contentUrl: json['content_url'] as String?, + sourceTld: json['source_tld'] as String?, + sourcePostUrl: json['source_post_url'] as String?, + isSticker: json['is_sticker'] as int?, + importDatetime: json['import_datetime'] == null + ? null + : DateTime.parse(json['import_datetime'] as String), + trendingDatetime: json['trending_datetime'] == null + ? null + : DateTime.parse(json['trending_datetime'] as String), + images: GiphyImages.fromJson( + json['images'] as Map, + ), + ); + String? title; + String? type; + String? id; + String? slug; + String? url; + String? bitlyGifUrl; + String? bitlyUrl; + String? embedUrl; + String? username; + String? source; + String? rating; + String? contentUrl; + String? sourceTld; + String? sourcePostUrl; + int? isSticker; + DateTime? importDatetime; + DateTime? trendingDatetime; + GiphyImages? images; + + Map toJson() => { + 'title': title, + 'type': type, + 'id': id, + 'slug': slug, + 'url': url, + 'bitly_gif_url': bitlyGifUrl, + 'bitly_url': bitlyUrl, + 'embed_url': embedUrl, + 'username': username, + 'source': source, + 'rating': rating, + 'content_url': contentUrl, + 'source_tld': sourceTld, + 'source_post_url': sourcePostUrl, + 'is_sticker': isSticker, + 'import_datetime': importDatetime?.toIso8601String(), + 'trending_datetime': trendingDatetime?.toIso8601String(), + 'images': images?.toJson() + }; + + @override + String toString() => + 'GiphyGif{title: $title, type: $type, id: $id, slug: $slug, url: $url, bitlyGifUrl: $bitlyGifUrl, bitlyUrl: $bitlyUrl, embedUrl: $embedUrl, username: $username, source: $source, rating: $rating, contentUrl: $contentUrl, sourceTld: $sourceTld, sourcePostUrl: $sourcePostUrl, isSticker: $isSticker, importDatetime: $importDatetime, trendingDatetime: $trendingDatetime, images: $images}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GiphyGif && + runtimeType == other.runtimeType && + title == other.title && + type == other.type && + id == other.id && + slug == other.slug && + url == other.url && + bitlyGifUrl == other.bitlyGifUrl && + bitlyUrl == other.bitlyUrl && + embedUrl == other.embedUrl && + username == other.username && + source == other.source && + rating == other.rating && + contentUrl == other.contentUrl && + sourceTld == other.sourceTld && + sourcePostUrl == other.sourcePostUrl && + isSticker == other.isSticker && + importDatetime == other.importDatetime && + trendingDatetime == other.trendingDatetime && + images == other.images; + + @override + int get hashCode => + title.hashCode ^ + type.hashCode ^ + id.hashCode ^ + slug.hashCode ^ + url.hashCode ^ + bitlyGifUrl.hashCode ^ + bitlyUrl.hashCode ^ + embedUrl.hashCode ^ + username.hashCode ^ + source.hashCode ^ + rating.hashCode ^ + contentUrl.hashCode ^ + sourceTld.hashCode ^ + sourcePostUrl.hashCode ^ + isSticker.hashCode ^ + importDatetime.hashCode ^ + trendingDatetime.hashCode ^ + images.hashCode; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/image.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/image.dart new file mode 100644 index 0000000000..fd88e51fec --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/image.dart @@ -0,0 +1,437 @@ +class GiphyFullImage { + factory GiphyFullImage.fromJson(Map json) => GiphyFullImage( + url: json['url'] as String, + width: json['width'] as String, + height: json['height'] as String, + size: json['size'] as String, + mp4: json['mp4'] as String?, + mp4Size: json['mp4_size'] as String?, + webp: json['webp'] as String?, + webpSize: json['webp_size'] as String?, + ); + + GiphyFullImage({ + required this.url, + required this.width, + required this.height, + required this.size, + required this.mp4, + required this.mp4Size, + required this.webp, + required this.webpSize, + }); + + final String url; + final String width; + final String height; + final String size; + final String? mp4; + final String? mp4Size; + final String? webp; + final String? webpSize; + + Map toJson() => { + 'url': url, + 'width': width, + 'height': height, + 'size': size, + 'mp4': mp4, + 'mp4_size': mp4Size, + 'webp': webp, + 'webp_size': webpSize + }; + + @override + String toString() => + 'GiphyFullImage{url: $url, width: $width, height: $height, size: $size, mp4: $mp4, mp4Size: $mp4Size, webp: $webp, webpSize: $webpSize}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GiphyFullImage && + runtimeType == other.runtimeType && + url == other.url && + width == other.width && + height == other.height && + size == other.size && + mp4 == other.mp4 && + mp4Size == other.mp4Size && + webp == other.webp && + webpSize == other.webpSize; + + @override + int get hashCode => + url.hashCode ^ + width.hashCode ^ + height.hashCode ^ + size.hashCode ^ + mp4.hashCode ^ + mp4Size.hashCode ^ + webp.hashCode ^ + webpSize.hashCode; +} + +class GiphyOriginalImage { + GiphyOriginalImage({ + required this.url, + required this.width, + required this.height, + required this.size, + required this.frames, + required this.mp4, + required this.mp4Size, + required this.webp, + required this.webpSize, + required this.hash, + }); + + factory GiphyOriginalImage.fromJson(Map json) => + GiphyOriginalImage( + url: json['url'] as String, + width: json['width'] as String, + height: json['height'] as String, + size: json['size'] as String, + frames: json['frames'] as String, + mp4: json['mp4'] as String, + mp4Size: json['mp4_size'] as String, + webp: json['webp'] as String?, + webpSize: json['webp_size'] as String?, + hash: json['hash'] as String, + ); + final String url; + final String width; + final String height; + final String size; + final String frames; + final String mp4; + final String mp4Size; + final String? webp; + final String? webpSize; + final String hash; + + Map toJson() => { + 'url': url, + 'width': width, + 'height': height, + 'size': size, + 'frames': frames, + 'mp4': mp4, + 'mp4_size': mp4Size, + 'webp': webp, + 'webp_size': webpSize, + 'hash': hash + }; + + @override + String toString() => + 'GiphyOriginalImage{url: $url, width: $width, height: $height, size: $size, frames: $frames, mp4: $mp4, mp4Size: $mp4Size, webp: $webp, webpSize: $webpSize, hash: $hash}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GiphyOriginalImage && + runtimeType == other.runtimeType && + url == other.url && + width == other.width && + height == other.height && + size == other.size && + frames == other.frames && + mp4 == other.mp4 && + mp4Size == other.mp4Size && + webp == other.webp && + webpSize == other.webpSize && + hash == other.hash; + + @override + int get hashCode => + url.hashCode ^ + width.hashCode ^ + height.hashCode ^ + size.hashCode ^ + frames.hashCode ^ + mp4.hashCode ^ + mp4Size.hashCode ^ + webp.hashCode ^ + webpSize.hashCode ^ + hash.hashCode; +} + +class GiphyStillImage { + GiphyStillImage({ + required this.url, + required this.width, + required this.height, + required this.size, + }); + + factory GiphyStillImage.fromJson(Map json) => + GiphyStillImage( + url: json['url'] as String, + width: json['width'] as String, + height: json['height'] as String, + size: json['size'] as String? ?? '', + ); + final String url; + final String width; + final String height; + final String size; + + Map toJson() => { + 'url': url, + 'width': width, + 'height': height, + 'size': size + }; + + @override + String toString() => + 'GiphyStillImage{url: $url, width: $width, height: $height, size: $size}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GiphyStillImage && + runtimeType == other.runtimeType && + url == other.url && + width == other.width && + height == other.height && + size == other.size; + + @override + int get hashCode => + url.hashCode ^ width.hashCode ^ height.hashCode ^ size.hashCode; +} + +class GiphyDownsampledImage { + GiphyDownsampledImage({ + required this.url, + required this.width, + required this.height, + required this.size, + required this.webp, + required this.webpSize, + }); + + factory GiphyDownsampledImage.fromJson(Map json) => + GiphyDownsampledImage( + url: json['url'] as String, + width: json['width'] as String, + height: json['height'] as String, + size: json['size'] as String, + webp: json['webp'] as String?, + webpSize: json['webp_size'] as String?, + ); + final String url; + final String width; + final String height; + final String size; + final String? webp; + final String? webpSize; + + Map toJson() => { + 'url': url, + 'width': width, + 'height': height, + 'size': size, + 'webp': webp, + 'webp_size': webpSize + }; + + @override + String toString() => + 'GiphyDownsampledImage{url: $url, width: $width, height: $height, size: $size, webp: $webp, webpSize: $webpSize}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GiphyDownsampledImage && + runtimeType == other.runtimeType && + url == other.url && + width == other.width && + height == other.height && + size == other.size && + webp == other.webp && + webpSize == other.webpSize; + + @override + int get hashCode => + url.hashCode ^ + width.hashCode ^ + height.hashCode ^ + size.hashCode ^ + webp.hashCode ^ + webpSize.hashCode; +} + +class GiphyLoopingImage { + GiphyLoopingImage({ + required this.mp4, + required this.mp4Size, + }); + + factory GiphyLoopingImage.fromJson( + Map json, + ) => + GiphyLoopingImage( + mp4: json['mp4'] as String, + mp4Size: json['mp4_size'] as String, + ); + final String mp4; + final String mp4Size; + + Map toJson() => { + 'mp4': mp4, + 'mp4_size': mp4Size, + }; + + @override + String toString() => 'GiphyLoopingImage{mp4: $mp4, mp4Size: $mp4Size}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GiphyLoopingImage && + runtimeType == other.runtimeType && + mp4 == other.mp4 && + mp4Size == other.mp4Size; + + @override + int get hashCode => mp4.hashCode ^ mp4Size.hashCode; +} + +class GiphyPreviewImage { + GiphyPreviewImage({ + required this.width, + required this.height, + required this.mp4, + required this.mp4Size, + }); + + factory GiphyPreviewImage.fromJson(Map json) => + GiphyPreviewImage( + width: json['width'] as String, + height: json['height'] as String, + mp4: json['mp4'] as String? ?? '', + mp4Size: json['mp4_size'] as String? ?? '', + ); + final String width; + final String height; + final String mp4; + final String mp4Size; + + Map toJson() => { + 'width': width, + 'height': height, + 'mp4': mp4, + 'mp4_size': mp4Size + }; + + @override + String toString() => + 'GiphyPreviewImage{width: $width, height: $height, mp4: $mp4, mp4Size: $mp4Size}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GiphyPreviewImage && + runtimeType == other.runtimeType && + width == other.width && + height == other.height && + mp4 == other.mp4 && + mp4Size == other.mp4Size; + + @override + int get hashCode => + width.hashCode ^ height.hashCode ^ mp4.hashCode ^ mp4Size.hashCode; +} + +class GiphyDownsizedImage { + GiphyDownsizedImage({ + required this.url, + required this.width, + required this.height, + required this.size, + }); + + factory GiphyDownsizedImage.fromJson(Map json) => + GiphyDownsizedImage( + url: json['url'] as String, + width: json['width'] as String, + height: json['height'] as String, + size: json['size'] as String, + ); + final String url; + final String width; + final String height; + final String size; + + Map toJson() => { + 'url': url, + 'width': width, + 'height': height, + 'size': size + }; + + @override + String toString() => + 'GiphyDownsizedImage{url: $url, width: $width, height: $height, size: $size}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GiphyDownsizedImage && + runtimeType == other.runtimeType && + url == other.url && + width == other.width && + height == other.height && + size == other.size; + + @override + int get hashCode => + url.hashCode ^ width.hashCode ^ height.hashCode ^ size.hashCode; +} + +class GiphyWebPImage { + GiphyWebPImage({ + required this.url, + required this.width, + required this.height, + required this.size, + }); + + factory GiphyWebPImage.fromJson(Map json) => GiphyWebPImage( + url: json['url'] as String, + width: json['width'] as String, + height: json['height'] as String, + size: json['size'] as String, + ); + final String url; + final String width; + final String height; + final String size; + + Map toJson() => { + 'url': url, + 'width': width, + 'height': height, + 'size': size + }; + + @override + String toString() => + 'GiphyWebPImage{url: $url, width: $width, height: $height, size: $size}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GiphyWebPImage && + runtimeType == other.runtimeType && + url == other.url && + width == other.width && + height == other.height && + size == other.size; + + @override + int get hashCode => + url.hashCode ^ width.hashCode ^ height.hashCode ^ size.hashCode; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/images.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/images.dart new file mode 100644 index 0000000000..4b34ca2b5a --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/images.dart @@ -0,0 +1,259 @@ +import 'image.dart'; + +class GiphyImages { + GiphyImages({ + required this.fixedHeightStill, + required this.originalStill, + required this.fixedWidth, + this.fixedHeightSmallStill, + this.fixedHeightDownsampled, + this.preview, + this.fixedHeightSmall, + this.downsizedStill, + this.downsized, + this.downsizedLarge, + this.fixedWidthSmallStill, + this.previewWebp, + this.fixedWidthStill, + this.fixedWidthSmall, + this.downsizedSmall, + this.fixedWidthDownsampled, + this.downsizedMedium, + this.original, + this.fixedHeight, + this.hd, + this.looping, + this.originalMp4, + this.previewGif, + this.w480Still, + }); + + factory GiphyImages.fromJson(Map json) => GiphyImages( + fixedHeightStill: GiphyStillImage.fromJson( + json['fixed_height_still'] as Map), + originalStill: GiphyStillImage.fromJson( + json['original_still'] as Map), + fixedWidth: GiphyFullImage.fromJson( + json['fixed_width'] as Map), + fixedHeightSmallStill: json['fixed_height_small_still'] == null || + (json['fixed_height_small_still'] as Map) + .isEmpty + ? null + : GiphyStillImage.fromJson( + json['fixed_height_small_still'] as Map), + fixedHeightDownsampled: json['fixed_height_downsampled'] == null || + (json['fixed_height_downsampled'] as Map) + .isEmpty + ? null + : GiphyDownsampledImage.fromJson( + json['fixed_height_downsampled'] as Map), + preview: json['preview'] == null || + (json['preview'] as Map).isEmpty + ? null + : GiphyPreviewImage.fromJson( + json['preview'] as Map), + fixedHeightSmall: json['fixed_height_small'] == null || + (json['fixed_height_small'] as Map).isEmpty + ? null + : GiphyFullImage.fromJson( + json['fixed_height_small'] as Map), + downsizedStill: json['downsized_still'] == null || + (json['downsized_still'] as Map).isEmpty + ? null + : GiphyStillImage.fromJson( + json['downsized_still'] as Map), + downsized: json['downsized'] == null || + (json['downsized'] as Map).isEmpty + ? null + : GiphyDownsizedImage.fromJson( + json['downsized'] as Map), + downsizedLarge: json['downsized_large'] == null || + (json['downsized_large'] as Map).isEmpty + ? null + : GiphyDownsizedImage.fromJson( + json['downsized_large'] as Map), + fixedWidthSmallStill: json['fixed_width_small_still'] == null || + (json['fixed_width_small_still'] as Map) + .isEmpty + ? null + : GiphyStillImage.fromJson( + json['fixed_width_small_still'] as Map), + previewWebp: json['preview_webp'] == null || + (json['preview_webp'] as Map).isEmpty + ? null + : GiphyWebPImage.fromJson( + json['preview_webp'] as Map), + fixedWidthStill: json['fixed_width_still'] == null || + (json['fixed_width_still'] as Map).isEmpty + ? null + : GiphyStillImage.fromJson( + json['fixed_width_still'] as Map), + fixedWidthSmall: json['fixed_width_small'] == null || + (json['fixed_width_small'] as Map).isEmpty + ? null + : GiphyFullImage.fromJson( + json['fixed_width_small'] as Map), + downsizedSmall: json['downsized_small'] == null || + (json['downsized_small'] as Map).isEmpty + ? null + : GiphyPreviewImage.fromJson( + json['downsized_small'] as Map), + fixedWidthDownsampled: json['fixed_width_downsampled'] == null || + (json['fixed_width_downsampled'] as Map) + .isEmpty + ? null + : GiphyDownsampledImage.fromJson( + json['fixed_width_downsampled'] as Map), + downsizedMedium: json['downsized_medium'] == null || + (json['downsized_medium'] as Map).isEmpty + ? null + : GiphyPreviewImage.fromJson( + json['downsized_medium'] as Map), + original: json['original'] == null || + (json['original'] as Map).isEmpty + ? null + : GiphyOriginalImage.fromJson( + json['original'] as Map), + fixedHeight: json['fixed_height'] == null || + (json['fixed_height'] as Map).isEmpty + ? null + : GiphyFullImage.fromJson( + json['fixed_height'] as Map), + hd: json['hd'] == null + ? null + : GiphyPreviewImage.fromJson(json['hd'] as Map), + looping: json['looping'] == null || + (json['looping'] as Map).isEmpty + ? null + : GiphyLoopingImage.fromJson( + json['looping'] as Map), + originalMp4: json['original_mp4'] == null || + (json['original_mp4'] as Map).isEmpty + ? null + : GiphyPreviewImage.fromJson( + json['original_mp4'] as Map), + previewGif: json['preview_gif'] == null || + (json['preview_gif'] as Map).isEmpty + ? null + : GiphyDownsizedImage.fromJson( + json['preview_gif'] as Map), + w480Still: json['480w_still'] == null || + (json['480w_still'] as Map).isEmpty + ? null + : GiphyStillImage.fromJson( + json['480w_still'] as Map), + ); + + GiphyStillImage fixedHeightStill; + GiphyStillImage originalStill; + GiphyFullImage fixedWidth; + GiphyStillImage? fixedHeightSmallStill; + GiphyDownsampledImage? fixedHeightDownsampled; + GiphyPreviewImage? preview; + GiphyFullImage? fixedHeightSmall; + GiphyStillImage? downsizedStill; + GiphyDownsizedImage? downsized; + GiphyDownsizedImage? downsizedLarge; + GiphyStillImage? fixedWidthSmallStill; + GiphyWebPImage? previewWebp; + GiphyStillImage? fixedWidthStill; + GiphyFullImage? fixedWidthSmall; + GiphyPreviewImage? downsizedSmall; + GiphyDownsampledImage? fixedWidthDownsampled; + GiphyPreviewImage? downsizedMedium; + GiphyOriginalImage? original; + GiphyFullImage? fixedHeight; + GiphyPreviewImage? hd; + GiphyLoopingImage? looping; + GiphyPreviewImage? originalMp4; + GiphyDownsizedImage? previewGif; + GiphyStillImage? w480Still; + + Map toJson() => { + 'fixed_height_still': fixedHeightStill.toJson(), + 'original_still': originalStill.toJson(), + 'fixed_width': fixedWidth.toJson(), + 'fixed_height_small_still': fixedHeightSmallStill?.toJson(), + 'fixed_height_downsampled': fixedHeightDownsampled?.toJson(), + 'preview': preview?.toJson(), + 'fixed_height_small': fixedHeightSmall?.toJson(), + 'downsized_still': downsizedStill?.toJson(), + 'downsized': downsized?.toJson(), + 'downsized_large': downsizedLarge?.toJson(), + 'fixed_width_small_still': fixedWidthSmallStill?.toJson(), + 'preview_webp': previewWebp?.toJson(), + 'fixed_width_still': fixedWidthStill?.toJson(), + 'fixed_width_small': fixedWidthSmall?.toJson(), + 'downsized_small': downsizedSmall?.toJson(), + 'fixed_width_downsampled': fixedWidthDownsampled?.toJson(), + 'downsized_medium': downsizedMedium?.toJson(), + 'original': original?.toJson(), + 'fixed_height': fixedHeight?.toJson(), + 'hd': hd?.toJson(), + 'looping': looping?.toJson(), + 'original_mp4': originalMp4?.toJson(), + 'preview_gif': previewGif?.toJson(), + '480w_still': w480Still?.toJson() + }; + + @override + String toString() => + 'GiphyImages{fixedHeightStill: $fixedHeightStill, originalStill: $originalStill, fixedWidth: $fixedWidth, fixedHeightSmallStill: $fixedHeightSmallStill, fixedHeightDownsampled: $fixedHeightDownsampled, preview: $preview, fixedHeightSmall: $fixedHeightSmall, downsizedStill: $downsizedStill, downsized: $downsized, downsizedLarge: $downsizedLarge, fixedWidthSmallStill: $fixedWidthSmallStill, previewWebp: $previewWebp, fixedWidthStill: $fixedWidthStill, fixedWidthSmall: $fixedWidthSmall, downsizedSmall: $downsizedSmall, fixedWidthDownsampled: $fixedWidthDownsampled, downsizedMedium: $downsizedMedium, original: $original, fixedHeight: $fixedHeight, hd: $hd, looping: $looping, originalMp4: $originalMp4, previewGif: $previewGif, w480Still: $w480Still}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GiphyImages && + runtimeType == other.runtimeType && + fixedHeightStill == other.fixedHeightStill && + originalStill == other.originalStill && + fixedWidth == other.fixedWidth && + fixedHeightSmallStill == other.fixedHeightSmallStill && + fixedHeightDownsampled == other.fixedHeightDownsampled && + preview == other.preview && + fixedHeightSmall == other.fixedHeightSmall && + downsizedStill == other.downsizedStill && + downsized == other.downsized && + downsizedLarge == other.downsizedLarge && + fixedWidthSmallStill == other.fixedWidthSmallStill && + previewWebp == other.previewWebp && + fixedWidthStill == other.fixedWidthStill && + fixedWidthSmall == other.fixedWidthSmall && + downsizedSmall == other.downsizedSmall && + fixedWidthDownsampled == other.fixedWidthDownsampled && + downsizedMedium == other.downsizedMedium && + original == other.original && + fixedHeight == other.fixedHeight && + hd == other.hd && + looping == other.looping && + originalMp4 == other.originalMp4 && + previewGif == other.previewGif && + w480Still == other.w480Still; + + @override + int get hashCode => + fixedHeightStill.hashCode ^ + originalStill.hashCode ^ + fixedWidth.hashCode ^ + fixedHeightSmallStill.hashCode ^ + fixedHeightDownsampled.hashCode ^ + preview.hashCode ^ + fixedHeightSmall.hashCode ^ + downsizedStill.hashCode ^ + downsized.hashCode ^ + downsizedLarge.hashCode ^ + fixedWidthSmallStill.hashCode ^ + previewWebp.hashCode ^ + fixedWidthStill.hashCode ^ + fixedWidthSmall.hashCode ^ + downsizedSmall.hashCode ^ + fixedWidthDownsampled.hashCode ^ + downsizedMedium.hashCode ^ + original.hashCode ^ + fixedHeight.hashCode ^ + hd.hashCode ^ + looping.hashCode ^ + originalMp4.hashCode ^ + previewGif.hashCode ^ + w480Still.hashCode; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/languages.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/languages.dart new file mode 100644 index 0000000000..a6d6f17307 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/languages.dart @@ -0,0 +1,34 @@ +class GiphyLanguage { + static const bengali = 'bn'; + static const chineseSimplified = 'zh-CN'; + static const chineseTraditional = 'zh-TW'; + static const czech = 'cs'; + static const english = 'en'; + static const spanish = 'es'; + static const portuguese = 'pt'; + static const indonesian = 'id'; + static const french = 'fr'; + static const arabic = 'ar'; + static const turkish = 'tr'; + static const thai = 'th'; + static const vietnamese = 'vi'; + static const german = 'de'; + static const italian = 'it'; + static const japanese = 'ja'; + static const russian = 'ru'; + static const korean = 'ko'; + static const polish = 'pl'; + static const dutch = 'nl'; + static const romanian = 'ro'; + static const hungarian = 'hu'; + static const swedish = 'sv'; + static const hindi = 'hi'; + static const danish = 'da'; + static const farsi = 'fa'; + static const filipino = 'tl'; + static const finnish = 'fi'; + static const hebrew = 'iw'; + static const malay = 'ms'; + static const norwegian = 'no'; + static const ukrainian = 'uk'; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/rating.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/rating.dart new file mode 100644 index 0000000000..b653156eb0 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/rating.dart @@ -0,0 +1,7 @@ +class GiphyRating { + static const y = 'y'; + static const g = 'g'; + static const pg = 'pg'; + static const pg13 = 'pg-13'; + static const r = 'r'; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/response.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/response.dart new file mode 100644 index 0000000000..aea3b363dd --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/response.dart @@ -0,0 +1,125 @@ +import 'gif.dart'; + +class GiphyResponse { + GiphyResponse( + {required this.data, required this.pagination, required this.meta}); + + factory GiphyResponse.fromJson(Map json) { + final pagination = json['pagination']; + final meta = json['meta']; + return GiphyResponse( + data: (json['data'] as List?) + ?.whereType>() + .map(GiphyGif.fromJson) + .toList( + growable: false, + ) ?? + List.empty(), + pagination: pagination == null + ? null + : GiphyPagination.fromJson( + pagination as Map, + ), + meta: meta == null + ? null + : GiphyMeta.fromJson(meta as Map)); + } + + final List data; + final GiphyPagination? pagination; + final GiphyMeta? meta; + + Map toJson() => + {'data': data, 'pagination': pagination, 'meta': meta}; + + @override + String toString() => + 'GiphyCollection{data: $data, pagination: $pagination, meta: $meta}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GiphyResponse && + runtimeType == other.runtimeType && + data == other.data && + pagination == other.pagination && + meta == other.meta; + + @override + int get hashCode => data.hashCode ^ pagination.hashCode ^ meta.hashCode; +} + +class GiphyPagination { + factory GiphyPagination.fromJson(Map json) => + GiphyPagination( + totalCount: json['total_count'] as int? ?? 0, + count: json['count'] as int? ?? 0, + offset: json['offset'] as int? ?? 0, + ); + + GiphyPagination( + {required this.totalCount, required this.count, required this.offset}); + + final int totalCount; + final int count; + final int offset; + + Map toJson() => { + 'total_count': totalCount, + 'count': count, + 'offset': offset + }; + + @override + String toString() => + 'GiphyPagination{totalCount: $totalCount, count: $count, offset: $offset}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GiphyPagination && + runtimeType == other.runtimeType && + totalCount == other.totalCount && + count == other.count && + offset == other.offset; + + @override + int get hashCode => totalCount.hashCode ^ count.hashCode ^ offset.hashCode; +} + +class GiphyMeta { + GiphyMeta( + {required this.status, required this.message, required this.responseId}); + + factory GiphyMeta.fromJson(Map json) => GiphyMeta( + status: json['status'] as int? ?? 0, + message: json['msg'] as String? ?? '', + responseId: json['response_id'] as String? ?? '', + ); + + final int status; + final String message; + final String responseId; + + Map toJson() => { + 'status': status, + 'msg': message, + 'response_id': responseId + }; + + @override + String toString() => + 'GiphyMeta{status: $status, msg: $message, responseId: $responseId}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GiphyMeta && + runtimeType == other.runtimeType && + status == other.status && + message == other.message && + responseId == other.responseId; + + @override + int get hashCode => status.hashCode ^ message.hashCode ^ responseId.hashCode; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/type.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/type.dart new file mode 100644 index 0000000000..8f87706b4e --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/client/models/type.dart @@ -0,0 +1,5 @@ +enum GiphyType { + gifs, + stickers, + emoji; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/giphy_picker.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/giphy_picker.dart new file mode 100644 index 0000000000..b0182f7f76 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/giphy/giphy_picker.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../themes/index.dart'; +import '../index.dart'; +import '../t_loading_indicator/t_loading_indicator.dart'; +import 'client/client.dart'; +import 'client/models/gif.dart'; +import 'client/models/response.dart'; +import 'client/models/type.dart'; + +const crossAxisCount = 5; +const limit = crossAxisCount * 10; + +final _queryTextViewModel = StateProvider((ref) => ''); + +class GiphyPicker extends ConsumerWidget { + const GiphyPicker({super.key, required this.onSelected}); + + final ValueChanged onSelected; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + return Column( + spacing: 8, + children: [ + TSearchBar( + hintText: appLocalizations.searchStickers, + onSubmitted: (value) { + ref.read(_queryTextViewModel.notifier).state = value; + }, + ), + Expanded( + child: GiphyPickerBody( + type: GiphyType.stickers, + scrollController: ScrollController(), + onSelected: onSelected, + ), + ), + ], + ); + } +} + +class GiphyPickerBody extends ConsumerStatefulWidget { + GiphyPickerBody( + {Key? key, + required this.type, + required this.scrollController, + required this.onSelected}) + : super(key: key); + + final GiphyType type; + final ScrollController scrollController; + final ValueChanged onSelected; + + @override + _GiphyPickerBodyState createState() => _GiphyPickerBodyState(); +} + +class _GiphyPickerBodyState extends ConsumerState { + GiphyResponse? _lastResponse; + + final List _gifs = []; + + final Axis _scrollDirection = Axis.vertical; + + // Spacing between gifs in grid + final double _spacing = 8.0; + + bool _isLoading = false; + + int _offset = 0; + + @override + void initState() { + super.initState(); + + widget.scrollController.addListener(_loadMoreIfScrollToEnd); + _loadMore(); + } + + @override + void dispose() { + widget.scrollController.removeListener(_loadMoreIfScrollToEnd); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final gifs = _gifs; + if (gifs.isEmpty) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + return Center( + child: TLoadingIndicator(text: appLocalizations.loading), + ); + } + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: _spacing, + crossAxisSpacing: _spacing, + ), + padding: Sizes.paddingH8, + scrollDirection: _scrollDirection, + controller: widget.scrollController, + itemCount: gifs.length, + itemBuilder: (ctx, idx) => _buildItem(gifs[idx]), + ); + } + + Widget _buildItem(GiphyGif gif) { + final images = gif.images!; + final image = images.fixedWidthSmall!; + final _aspectRatio = double.parse(image.width) / double.parse(image.height); + final url = image.url; + return ClipRRect( + borderRadius: Sizes.borderRadiusCircular8, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => widget.onSelected(gif), + child: RepaintBoundary( + child: LayoutBuilder(builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + return Image.network( + url, + cacheWidth: maxWidth.toInt(), + cacheHeight: maxWidth ~/ _aspectRatio, + semanticLabel: gif.title, + gaplessPlayback: true, + fit: BoxFit.fill, + headers: {'accept': 'image/*'}, + ); + }), + ), + ), + ), + ); + } + + Future _loadMore() async { + var response = _lastResponse; + if (_isLoading || response?.pagination?.totalCount == _gifs.length) { + return; + } + + _isLoading = true; + + final client = ref.read(giphyClientProvider); + + _offset = response == null + ? 0 + : response.pagination!.offset + response.pagination!.count; + + final type = widget.type; + if (type == GiphyType.emoji) { + response = await client.emojis(offset: _offset, limit: limit); + } else { + final queryText = ref.read(_queryTextViewModel); + if (queryText.isNotEmpty) { + response = await client.search(queryText, + offset: _offset, type: type, limit: limit); + } else { + response = + await client.trending(offset: _offset, type: type, limit: limit); + } + } + + _lastResponse = response; + if (response.data.isNotEmpty && mounted) { + _gifs.addAll(response.data); + setState(() {}); + } + + _isLoading = false; + } + + void _loadMoreIfScrollToEnd() { + // TODO + // if (widget.scrollController.positions.last.extentAfter.lessThan(500) && + // !_isLoading) { + // _loadMore(); + // } + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/index.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/index.dart new file mode 100644 index 0000000000..ef1a28e3fb --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/index.dart @@ -0,0 +1,45 @@ +export 'giphy/giphy_picker.dart'; +export 't_accordion/t_accordion.dart'; +export 't_alert/t_alert.dart'; +export 't_async_builder/t_async_builder.dart'; +export 't_audio_player/t_audio_player.dart'; +export 't_avatar/t_avatar.dart'; +export 't_button/t_icon_button.dart'; +export 't_button/t_text_button.dart'; +export 't_checkbox/t_checkbox.dart'; +export 't_checkbox/t_simple_checkbox.dart'; +export 't_circle/t_circle.dart'; +export 't_date_picker/t_date_picker.dart'; +export 't_date_range_picker/t_date_range_picker.dart'; +export 't_dialog/t_dialog.dart'; +export 't_divider/t_horizontal_divider.dart'; +export 't_divider/t_vertical_divider.dart'; +export 't_drawer/t_drawer.dart'; +export 't_empty/t_empty.dart'; +export 't_empty/t_empty_result.dart'; +export 't_focus_tracker/t_focus_tracker.dart'; +export 't_form/t_form.dart'; +export 't_image/t_image_broken.dart'; +export 't_image_viewer/t_image_viewer.dart'; +export 't_layout/t_responsive_layout.dart'; +export 't_lazy_indexed_stack/t_lazy_indexed_stack.dart'; +export 't_list_tile/t_list_tile.dart'; +export 't_menu/t_menu.dart'; +export 't_menu/t_menu_popup.dart'; +export 't_popup/t_popup.dart'; +export 't_popup/t_popup_follower.dart'; +export 't_radio/t_radio.dart'; +export 't_search_bar/t_search_bar.dart'; +export 't_selection/t_selectable_region.dart'; +export 't_selection/t_selection_area.dart'; +export 't_selection/t_selection_container.dart'; +export 't_shortcut_text_field/t_shortcut_text_field.dart'; +export 't_switch/t_switch.dart'; +export 't_table/t_table.dart'; +export 't_tabs/t_tabs.dart'; +export 't_text/t_text.dart'; +export 't_text_field/t_text_field.dart'; +export 't_title_bar/t_title_bar.dart'; +export 't_toast/t_toast.dart'; +export 't_tooltip/t_tooltip.dart'; +export 't_window_control_zone/t_window_control_zone.dart'; diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_accordion/t_accordion.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_accordion/t_accordion.dart new file mode 100644 index 0000000000..d9e8f3e7b2 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_accordion/t_accordion.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../themes/index.dart'; + +const _defaultAnimationDuration = Duration(milliseconds: 200); + +class TAccordion extends StatefulWidget { + const TAccordion( + {Key? key, + this.controller, + this.showAccordion = false, + required this.titleChild, + required this.contentChild, + this.collapsedTitleBackgroundColor = Colors.transparent, + this.expandedTitleBackgroundColor = Colors.transparent, + this.titlePadding = Sizes.paddingV4H4, + this.contentBackgroundColor, + this.contentPadding = EdgeInsets.zero, + this.titleBorder, + this.contentBorder, + this.onToggleCollapsed, + this.titleBorderRadius = BorderRadius.zero, + this.contentBorderRadius = BorderRadius.zero}) + : super(key: key); + + final TAccordionController? controller; + + final bool showAccordion; + + final Widget titleChild; + + final Widget contentChild; + + final Color collapsedTitleBackgroundColor; + + final Color expandedTitleBackgroundColor; + + final EdgeInsets titlePadding; + + final EdgeInsets contentPadding; + + final Color? contentBackgroundColor; + + final Border? titleBorder; + + final Border? contentBorder; + + final BorderRadius titleBorderRadius; + + final BorderRadius contentBorderRadius; + + final void Function(bool)? onToggleCollapsed; + + @override + _TAccordionState createState() => _TAccordionState(); +} + +class _TAccordionState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _turnsAnimation; + late CurvedAnimation _sizeAnimation; + late bool _showAccordion; + bool _buildChild = true; + + final List _onOpenCompletedCallbacks = []; + + @override + void initState() { + super.initState(); + widget.controller?.open = _open; + widget.controller?.close = _close; + _showAccordion = widget.showAccordion; + _animationController = + AnimationController(duration: _defaultAnimationDuration, vsync: this) + ..addListener(() { + if (_animationController.isDismissed) { + _buildChild = false; + setState(() {}); + } else if (_animationController.isCompleted) { + for (final callback in _onOpenCompletedCallbacks) { + callback(); + } + _onOpenCompletedCallbacks.clear(); + } + }); + _turnsAnimation = _animationController.drive(Tween( + begin: 0, + end: 90 / 360, + )); + _sizeAnimation = CurvedAnimation( + parent: _animationController, + curve: Curves.fastOutSlowIn, + ); + } + + @override + void dispose() { + widget.controller?.open = null; + widget.controller?.close = null; + _animationController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(TAccordion oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + widget.controller?.open = _open; + widget.controller?.close = _close; + } + } + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: _toggleCollapsed, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: widget.titleBorderRadius, + border: widget.titleBorder, + color: _showAccordion + ? widget.expandedTitleBackgroundColor + : widget.collapsedTitleBackgroundColor, + ), + child: Padding( + padding: widget.titlePadding, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + RotationTransition( + turns: _turnsAnimation, + child: + const Icon(Symbols.keyboard_arrow_right_rounded)), + Expanded( + child: widget.titleChild, + ) + ], + ), + ), + ), + ), + ), + if (_buildChild) + SizeTransition( + sizeFactor: _sizeAnimation, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: widget.contentBorderRadius, + border: widget.contentBorder, + color: widget.contentBackgroundColor ?? Colors.white70, + ), + child: Padding( + padding: widget.contentPadding, + child: widget.contentChild, + ), + ), + ) + ], + ); + + void _toggleCollapsed() { + switch (_animationController.status) { + case AnimationStatus.completed: + _close(); + break; + case AnimationStatus.dismissed: + _open(); + break; + default: + } + } + + bool _open({VoidCallback? onOpenCompleted}) { + if (_showAccordion) { + return false; + } + if (onOpenCompleted != null) { + _onOpenCompletedCallbacks.add(onOpenCompleted); + } + _animationController.forward(); + _buildChild = true; + _showAccordion = true; + widget.onToggleCollapsed?.call(true); + setState(() {}); + return true; + } + + bool _close() { + if (!_showAccordion) { + return false; + } + _animationController.reverse(); + _showAccordion = false; + widget.onToggleCollapsed?.call(false); + setState(() {}); + return true; + } +} + +class TAccordionController { + TAccordionController({this.open, this.close}); + + bool Function({VoidCallback? onOpenCompleted})? open; + bool Function()? close; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_alert/t_alert.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_alert/t_alert.dart new file mode 100644 index 0000000000..17095e56f8 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_alert/t_alert.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../themes/index.dart'; +import '../t_button/t_text_button.dart'; +import '../t_dialog/t_dialog.dart'; + +class TAlert extends ConsumerWidget { + const TAlert({ + super.key, + this.title, + required this.content, + this.width, + this.onTapCancel, + required this.onTapConfirm, + }); + + final String? title; + final Widget content; + final double? width; + final VoidCallback? onTapCancel; + final VoidCallback onTapConfirm; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + final theme = context.theme; + return SizedBox( + width: Sizes.alertWidth, + height: Sizes.alertHeight, + child: Padding( + padding: Sizes.paddingV16H16, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title case final title?) ...[ + Text( + title, + style: theme.textTheme.titleMedium, + ), + ], + content, + Align( + alignment: Alignment.bottomRight, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (onTapCancel != null) + TTextButton( + text: appLocalizations.cancel, + containerPadding: Sizes.paddingV4H8, + onTap: onTapCancel, + ), + TTextButton( + text: appLocalizations.confirm, + containerPadding: Sizes.paddingV4H8, + onTap: onTapConfirm), + ], + ), + ), + ], + ), + ), + ); + } +} + +Future showAlertDialog(BuildContext context, + {String? title, + required String content, + VoidCallback? onTapCancel, + required VoidCallback onTapConfirm}) => + showCustomTDialog( + routeName: '/t-alert', + context: context, + borderRadius: Sizes.borderRadiusCircular8, + child: TAlert( + title: title, + content: Text( + content, + ), + onTapCancel: onTapCancel, + onTapConfirm: onTapConfirm, + )); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_async_builder/t_async_builder.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_async_builder/t_async_builder.dart new file mode 100644 index 0000000000..b557469fd4 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_async_builder/t_async_builder.dart @@ -0,0 +1,32 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +typedef AsyncWidgetBuilder = Widget Function( + BuildContext context, AsyncValue snapshot); + +/// see [FutureBuilder] +class TAsyncBuilder extends StatelessWidget { + const TAsyncBuilder({super.key, required this.future, required this.builder}); + + final Future future; + final AsyncWidgetBuilder builder; + + @override + Widget build(BuildContext context) => FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.error case final error?) { + return builder( + context, + AsyncValue.error( + error, snapshot.stackTrace ?? StackTrace.empty)); + } else { + return builder(context, AsyncValue.data(snapshot.data)); + } + } + return builder(context, const AsyncValue.loading()); + }, + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_audio_player/t_audio_player.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_audio_player/t_audio_player.dart new file mode 100644 index 0000000000..6877e6769b --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_audio_player/t_audio_player.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../themes/index.dart'; + +class TAudioPlayer extends StatefulWidget { + const TAudioPlayer({Key? key}) : super(key: key); + + @override + _TAudioPlayerState createState() => _TAudioPlayerState(); +} + +// TODO +class _TAudioPlayerState extends State { + @override + Widget build(BuildContext context) => SizedBox( + height: 64, + child: DecoratedBox( + decoration: const BoxDecoration( + color: Colors.black87, + borderRadius: Sizes.borderRadiusCircular8, + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Symbols.play_arrow_rounded), + onPressed: () {}, + ), + const Text('00:10/00:21') + ], + ), + ), + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_avatar/t_avatar.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_avatar/t_avatar.dart new file mode 100644 index 0000000000..5f8c592a83 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_avatar/t_avatar.dart @@ -0,0 +1,321 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../infra/math/math_utils.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../themes/index.dart'; +import '../index.dart'; + +/// The colors are copied from [Colors.primaries] but excluding gray colors. +const List _colors = [ + Colors.red, + Colors.pink, + Colors.purple, + Colors.deepPurple, + Colors.indigo, + Colors.blue, + Colors.lightBlue, + Colors.cyan, + Colors.teal, + Colors.green, + Colors.lightGreen, + Colors.lime, + // Colors.yellow, + Colors.amber, + Colors.orange, + Colors.deepOrange, + Colors.brown, +]; + +final _colorCount = _colors.length; + +const _presenceBoxDiameter = 14.0; +const _prefixIconDimension = 12.0; + +class TAvatar extends ConsumerWidget { + TAvatar({ + super.key, + required this.id, + required this.name, + this.image, + this.icon, + this.size = TAvatarSize.medium, + this.textSize, + this.presence = UserPresence.none, + this.onPresenceSelected, + this.presencePopupOffset, + }) : firstChar = name.isEmpty ? '' : name.substring(0, 1); + + /// This can be any ID (e.g. user ID, group ID). + final Int64 id; + final String name; + final String firstChar; + final ImageProvider? image; + final IconData? icon; + final TAvatarSize size; + final double? textSize; + final UserPresence presence; + final ValueChanged? onPresenceSelected; + final Offset? presencePopupOffset; + + /// Use oval instead of rounded rect so that + /// the user presence indicator can display nicely with the avatar. + @override + Widget build(BuildContext context, WidgetRef ref) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + final avatar = ClipRRect( + borderRadius: Sizes.borderRadiusCircular4, + child: _buildAvatar(context.theme), + ); + if (presence == UserPresence.none) { + return avatar; + } + return Stack( + clipBehavior: Clip.none, + children: [avatar, _buildPresence(appLocalizations)]); + } + + Widget _buildAvatar(ThemeData theme) { + final containerSize = size.containerSize; + return SizedBox( + width: containerSize, + height: containerSize, + child: _buildContent(image, theme, theme.appThemeExtension), + ); + } + + Widget _buildContent(ImageProvider? img, ThemeData theme, + AppThemeExtension appThemeExtension) { + if (null != img) { + // FittedBox is used as a fallback in case the image is not fitted. + return FittedBox(child: Image(image: img)); + } + if (null != icon) { + return ColoredBox( + color: theme.primaryColor, + child: Icon( + icon, + fill: 1, + color: Colors.white, + size: size.iconSize, + )); + } + if (name.isEmpty) { + return ColoredBox( + color: appThemeExtension.avatarBackgroundColor, + child: Icon(Symbols.person_rounded, + fill: 1, + color: appThemeExtension.avatarIconColor, + // The "person" icon seems smaller than other icons, + // so we need to enlarge it. + size: size.iconSize * 1.25), + ); + } + return ColoredBox( + color: _pickColor(id), + child: Center( + child: Text( + firstChar, + textScaler: TextScaler.noScaling, + style: TextStyle( + fontSize: textSize ?? size.textSize, color: Colors.white), + ), + ), + ); + } + + Color _pickColor(Int64 userId) => _colors[(userId % _colorCount).toInt()]; + + Widget _buildPresence(AppLocalizations appLocalizations) { + Widget content = SizedBox( + width: _presenceBoxDiameter, + height: _presenceBoxDiameter, + child: CustomPaint( + painter: _TAvatarUserPresencePainter(presence), + ), + ); + if (onPresenceSelected case final onPresenceSelected?) { + content = TMenuPopup( + anchor: content, + targetAnchor: Alignment.topRight, + followerAnchor: Alignment.topLeft, + constrainFollowerWithTargetWidth: false, + offset: presencePopupOffset ?? Offset.zero, + padding: Sizes.paddingV8H16, + entries: [ + TMenuEntry( + value: UserPresence.available, + label: appLocalizations.userPresenceAvailable, + prefix: _buildMenuEntryPrefix(UserPresence.available), + ), + TMenuEntry( + value: UserPresence.busy, + label: appLocalizations.userPresenceBusy, + prefix: _buildMenuEntryPrefix(UserPresence.busy), + ), + TMenuEntry( + value: UserPresence.doNotDisturb, + label: appLocalizations.userPresenceDoNotDisturb, + prefix: _buildMenuEntryPrefix(UserPresence.doNotDisturb), + ), + TMenuEntry( + value: UserPresence.away, + label: appLocalizations.userPresenceAway, + prefix: _buildMenuEntryPrefix(UserPresence.away), + ), + TMenuEntry( + value: UserPresence.appearOffline, + label: appLocalizations.userPresenceAppearOffline, + prefix: _buildMenuEntryPrefix(UserPresence.appearOffline), + ), + ], + onSelected: (item) { + onPresenceSelected(item.value!); + }, + ); + } + return Positioned( + child: content, + right: 0, + bottom: 0, + ); + } + + SizedBox _buildMenuEntryPrefix(UserPresence presence) => SizedBox( + width: _prefixIconDimension, + height: _prefixIconDimension, + child: CustomPaint( + painter: _TAvatarUserPresencePainter(presence, hasBorder: false), + ), + ); +} + +enum UserPresence { + none, + available, + away, + busy, + doNotDisturb, + appearOffline, + offline +} + +const _padding = 1.0; +const _clockPointDistanceFromEdge = 3.0; +final _clockPoint = MathUtils.calculatePoint( + _presenceBoxDiameter / 2, + _presenceBoxDiameter / 2, + _presenceBoxDiameter / 2, + _clockPointDistanceFromEdge, + 45); + +class _TAvatarUserPresencePainter extends CustomPainter { + const _TAvatarUserPresencePainter(this.presence, {this.hasBorder = true}); + + final UserPresence presence; + final bool hasBorder; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint(); + + final centerX = size.width / 2; + final centerY = size.height / 2; + if (hasBorder) { + canvas.drawCircle( + Offset(centerX, centerY), + centerX, + paint + ..color = Colors.white + ..style = PaintingStyle.fill); + } + switch (presence) { + case UserPresence.available: + canvas.drawCircle( + Offset(centerX, centerY), + centerX - _padding, + paint + ..color = Colors.green + ..style = PaintingStyle.fill); + break; + case UserPresence.away: + canvas + ..drawCircle(Offset(centerX, centerY), centerX - _padding, + paint..color = Colors.orangeAccent) + ..drawPath( + Path() + ..moveTo(centerX, _clockPointDistanceFromEdge) + ..lineTo(centerX, centerY) + ..lineTo(_clockPoint.x, _clockPoint.y), + paint + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 1); + break; + case UserPresence.busy: + canvas.drawCircle(Offset(centerX, centerY), centerX - _padding, + paint..color = Colors.red); + break; + case UserPresence.doNotDisturb: + final padding = size.width / 3.5; + canvas + ..drawCircle(Offset(centerX, centerY), centerX - _padding, + paint..color = Colors.red) + ..drawPath( + Path() + ..moveTo(padding, centerY) + ..lineTo(size.width - padding, centerY), + paint + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 1); + break; + case UserPresence.appearOffline: + case UserPresence.offline: + final padding = size.width / 6; + canvas + ..drawCircle( + Offset(centerX, centerY), + centerX - _padding, + paint + ..color = Colors.grey.shade600 + ..style = PaintingStyle.fill) + ..drawCircle(Offset(centerX, centerY), centerX - _padding * 2, + paint..color = Colors.white) + ..drawPath( + Path() + ..moveTo(centerX - padding, centerY - padding) + ..lineTo(centerX + padding, centerY + padding) + ..moveTo(centerX - padding, centerY + padding) + ..lineTo(centerX + padding, centerY - padding), + paint + ..color = Colors.grey.shade600 + ..style = PaintingStyle.stroke + ..strokeWidth = 1); + break; + case UserPresence.none: + throw ArgumentError('presence must be set'); + } + } + + @override + bool shouldRepaint(_TAvatarUserPresencePainter oldDelegate) => + oldDelegate.presence != presence; +} + +enum TAvatarSize { + small(30), + medium(40), + large(60); + + const TAvatarSize(this.containerSize) + : textSize = containerSize * 0.5, + iconSize = containerSize * 0.75; + + final double containerSize; + final double textSize; + final double iconSize; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_button/t_button.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_button/t_button.dart new file mode 100644 index 0000000000..3bac9f7cca --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_button/t_button.dart @@ -0,0 +1,164 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import '../../../themes/index.dart'; +import '../t_tooltip/t_tooltip.dart'; + +class TButton extends StatefulWidget { + const TButton( + {super.key, + required this.addContainer, + this.containerWidth, + this.containerHeight, + this.containerBlendMode, + this.containerColor, + this.containerColorHovered, + this.containerColorPressed, + this.containerPadding, + this.containerBorder, + this.containerBorderHovered, + this.containerBorderRadius = Sizes.borderRadiusCircular4, + this.duration = Durations.short4, + this.isLoading = false, + this.disabled = false, + this.tooltip, + this.onTap, + this.onPanDown, + this.prefix, + this.childHovered, + this.childPressed, + required this.child}); + + final bool addContainer; + final double? containerWidth; + final double? containerHeight; + final BlendMode? containerBlendMode; + final Color? containerColor; + final Color? containerColorHovered; + final Color? containerColorPressed; + final EdgeInsets? containerPadding; + final BoxBorder? containerBorder; + final BoxBorder? containerBorderHovered; + final BorderRadiusGeometry? containerBorderRadius; + final Duration duration; + + final bool isLoading; + final bool disabled; + final String? tooltip; + final VoidCallback? onTap; + final GestureDragDownCallback? onPanDown; + + final Widget? prefix; + final Widget? childHovered; + final Widget? childPressed; + final Widget child; + + @override + State createState() => _TButtonState(); +} + +class _TButtonState extends State { + bool _isHovered = false; + bool _isPressed = false; + + @override + Widget build(BuildContext context) { + final theme = context.theme; + var child = _isPressed + ? (widget.childPressed ?? widget.childHovered ?? widget.child) + : _isHovered + ? widget.childHovered ?? widget.child + : widget.child; + if (widget.prefix case final prefix?) { + child = Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [prefix, child]); + } + if (widget.isLoading) { + child = Stack( + children: [ + Visibility.maintain( + child: child, + visible: false, + ), + Positioned.fill(child: Center( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final size = + min(constraints.maxWidth, constraints.maxHeight) * 0.8; + return SizedBox( + width: size, + height: size, + child: const CircularProgressIndicator( + color: Colors.white, + ), + ); + }, + ), + )) + ], + ); + } + if (widget.addContainer) { + child = AnimatedContainer( + duration: widget.duration, + decoration: BoxDecoration( + backgroundBlendMode: widget.containerBlendMode, + color: widget.disabled + ? theme.disabledColor + : widget.isLoading + ? widget.containerColor?.withValues(alpha: 0.5) + : _isPressed && widget.containerColorPressed != null + ? widget.containerColorPressed! + : _isHovered + ? (widget.containerColorHovered ?? + widget.containerColor?.withValues(alpha: 0.8)) + : widget.containerColor, + borderRadius: widget.containerBorderRadius, + border: _isHovered + ? (widget.containerBorderHovered ?? widget.containerBorder) + : widget.containerBorder, + ), + padding: widget.containerPadding, + width: widget.containerWidth, + height: widget.containerHeight, + child: child); + } + if (widget.tooltip case final tooltip?) { + child = TTooltip( + message: tooltip, + preferBelow: true, + waitDuration: Durations.extralong4, + child: child, + ); + } + return RepaintBoundary( + child: MouseRegion( + cursor: widget.disabled + ? SystemMouseCursors.forbidden + : SystemMouseCursors.click, + onEnter: (_) { + _isHovered = true; + setState(() {}); + }, + onExit: (_) { + _isHovered = false; + _isPressed = false; + setState(() {}); + }, + child: GestureDetector( + onTap: !widget.disabled && !widget.isLoading && widget.onTap != null + ? widget.onTap + : null, + onPanDown: (details) { + _isPressed = true; + widget.onPanDown?.call(details); + setState(() {}); + }, + child: child, + )), + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_button/t_icon_button.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_button/t_icon_button.dart new file mode 100644 index 0000000000..6ff786f510 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_button/t_icon_button.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/index.dart'; +import 't_button.dart'; + +class TIconButton extends StatelessWidget { + const TIconButton( + {super.key, + this.addContainer = true, + this.containerSize, + this.containerBlendMode, + this.containerColor, + this.containerColorHovered, + this.containerPadding, + this.containerBorder, + this.containerBorderHovered, + this.containerBorderRadius, + required this.iconData, + this.iconFill, + this.iconSize, + this.iconWeight, + this.iconColor, + this.iconColorHovered, + this.iconColorPressed, + this.iconFlipX = false, + this.iconRotate, + this.disabled = false, + this.tooltip, + this.onTap, + this.onPanDown}); + + factory TIconButton.outlined( + {required ThemeData theme, + required IconData iconData, + bool? iconFill, + double? iconSize, + double? iconWeight, + Color? iconColor, + Color? iconColorHovered, + Color? iconColorPressed, + bool? iconFlipX, + double? iconRotate, + Size? containerSize, + BlendMode? containerBlendMode, + bool disabled = false, + String? tooltip, + VoidCallback? onTap}) => + TIconButton( + iconData: iconData, + iconFill: iconFill, + iconSize: iconSize, + iconWeight: iconWeight, + iconColor: iconColor, + iconColorHovered: iconColorHovered, + iconColorPressed: iconColorPressed, + iconFlipX: iconFlipX ?? false, + iconRotate: iconRotate, + onTap: onTap, + containerSize: containerSize, + containerBlendMode: containerBlendMode, + containerColor: Colors.white, + containerBorder: Border.all(color: theme.dividerColor), + containerBorderHovered: Border.all(color: theme.primaryColor), + disabled: disabled, + tooltip: tooltip, + ); + + final bool addContainer; + final Size? containerSize; + final BlendMode? containerBlendMode; + final Color? containerColor; + final Color? containerColorHovered; + final EdgeInsets? containerPadding; + final BoxBorder? containerBorder; + final BoxBorder? containerBorderHovered; + final BorderRadiusGeometry? containerBorderRadius; + + final bool disabled; + final String? tooltip; + + final VoidCallback? onTap; + final GestureDragDownCallback? onPanDown; + + final IconData iconData; + final bool? iconFill; + final double? iconSize; + final double? iconWeight; + final Color? iconColor; + final Color? iconColorHovered; + final Color? iconColorPressed; + final bool iconFlipX; + final double? iconRotate; + + @override + Widget build(BuildContext context) { + final appThemeExtension = context.appThemeExtension; + final iconTheme = IconTheme.of(context); + return TButton( + addContainer: addContainer, + containerWidth: containerSize?.width ?? 40, + containerHeight: containerSize?.height ?? 40, + containerBlendMode: containerBlendMode, + containerColor: containerColor, + containerColorHovered: containerColorHovered ?? + appThemeExtension.iconButtonContainerHoveredColor, + containerColorPressed: + appThemeExtension.iconButtonContainerPressedColor, + containerPadding: containerPadding, + containerBorder: containerBorder, + containerBorderHovered: containerBorderHovered, + containerBorderRadius: + containerBorderRadius ?? Sizes.borderRadiusCircular4, + disabled: disabled, + tooltip: tooltip, + onTap: onTap, + onPanDown: onPanDown, + childHovered: _buildIcon(iconTheme, isHovered: true), + childPressed: _buildIcon(iconTheme, isPressed: true), + child: _buildIcon(iconTheme)); + } + + Widget _buildIcon(IconThemeData iconTheme, + {bool isPressed = false, bool isHovered = false}) { + Widget child = Icon( + iconData, + fill: (iconFill ?? false) ? 1 : 0, + color: isPressed + ? (iconColorPressed ?? iconColorHovered ?? iconColor) + : isHovered + ? (iconColorHovered ?? iconColor) + : iconColor, + weight: iconWeight ?? iconTheme.weight, + size: iconSize ?? iconTheme.size, + ); + if (iconFlipX) { + child = Transform.flip( + flipX: true, + child: child, + ); + } + if (iconRotate case final iconRotate?) { + child = Transform.rotate( + angle: iconRotate, + child: child, + ); + } + return child; + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_button/t_text_button.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_button/t_text_button.dart new file mode 100644 index 0000000000..b5e17bad5c --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_button/t_text_button.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/index.dart'; +import 't_button.dart'; + +class TTextButton extends StatelessWidget { + const TTextButton( + {super.key, + required this.text, + this.textStyle, + this.textStyleHovered, + this.addContainer = true, + this.containerWidth, + this.containerHeight, + this.containerColor, + this.containerColorHovered, + EdgeInsets? containerPadding, + this.containerBorder, + this.containerBorderHovered, + this.isLoading = false, + this.disabled = false, + this.prefix, + this.onTap}) + : containerPadding = containerPadding ?? + (containerHeight == null ? Sizes.paddingV8H16 : null); + + factory TTextButton.outlined( + {required ThemeData theme, + required String text, + double? containerWidth, + double? containerHeight, + EdgeInsets? containerPadding, + Widget? prefix, + VoidCallback? onTap}) => + TTextButton( + text: text, + textStyle: const TextStyle(color: Colors.black), + textStyleHovered: TextStyle(color: theme.primaryColor), + containerBorder: Border.all(color: theme.dividerColor), + containerBorderHovered: Border.all(color: theme.primaryColor), + containerColor: Colors.white, + containerWidth: containerWidth, + containerHeight: containerHeight, + containerPadding: containerPadding, + prefix: prefix, + onTap: onTap, + ); + + final bool addContainer; + final double? containerWidth; + final double? containerHeight; + final Color? containerColor; + final Color? containerColorHovered; + final EdgeInsets? containerPadding; + final BoxBorder? containerBorder; + final BoxBorder? containerBorderHovered; + final bool isLoading; + final bool disabled; + final VoidCallback? onTap; + + final String text; + final TextStyle? textStyle; + final TextStyle? textStyleHovered; + + final Widget? prefix; + + @override + Widget build(BuildContext context) => TButton( + addContainer: addContainer, + containerWidth: containerWidth, + containerHeight: containerHeight, + containerColor: containerColor ?? context.theme.primaryColor, + containerColorHovered: containerColorHovered, + containerPadding: containerPadding, + containerBorder: containerBorder, + containerBorderHovered: containerBorderHovered, + isLoading: isLoading, + disabled: disabled, + onTap: onTap, + childHovered: IntrinsicWidth( + child: Center( + child: AnimatedDefaultTextStyle( + style: textStyleHovered ?? + textStyle ?? + const TextStyle(color: Colors.white), + duration: const Duration(milliseconds: 200), + child: Text( + text, + textAlign: TextAlign.center, + ), + ), + ), + ), + prefix: prefix, + child: IntrinsicWidth( + child: Center( + child: AnimatedDefaultTextStyle( + style: textStyle ?? const TextStyle(color: Colors.white), + duration: const Duration(milliseconds: 200), + child: Text( + text, + textAlign: TextAlign.center, + ), + ), + ), + )); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_checkbox/t_checkbox.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_checkbox/t_checkbox.dart new file mode 100644 index 0000000000..71330f34a0 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_checkbox/t_checkbox.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/index.dart'; + +class TCheckbox extends StatefulWidget { + const TCheckbox(this.initialValue, this.text, + {super.key, required this.onCheckedChanged}); + + final bool initialValue; + final String text; + + final void Function(bool checked) onCheckedChanged; + + @override + State createState() => _TCheckboxState(); +} + +class _TCheckboxState extends State { + bool? _isChecked; + + @override + Widget build(BuildContext context) { + _isChecked ??= widget.initialValue; + final isChecked = _isChecked!; + final appThemeExtension = context.appThemeExtension; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + _isChecked = !isChecked; + widget.onCheckedChanged(isChecked); + setState(() {}); + }, + child: Row(spacing: 4, mainAxisSize: MainAxisSize.min, children: [ + AbsorbPointer( + child: Checkbox( + // focusNode: FocusNode(skipTraversal: true), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: + const VisualDensity(horizontal: -4, vertical: -4), + splashRadius: 0, + side: BorderSide( + color: appThemeExtension.checkboxColor, + width: 2, + ), + checkColor: Colors.white, + value: _isChecked, + onChanged: (bool? value) { + setState(() { + _isChecked = !isChecked; + }); + widget.onCheckedChanged(isChecked); + }, + ), + ), + Text(widget.text, style: appThemeExtension.checkboxTextStyle) + ]), + )); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_checkbox/t_simple_checkbox.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_checkbox/t_simple_checkbox.dart new file mode 100644 index 0000000000..c8cb8ec0bb --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_checkbox/t_simple_checkbox.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../themes/index.dart'; + +class TSimpleCheckbox extends StatefulWidget { + const TSimpleCheckbox({ + Key? key, + this.size = 16, + this.activeBackgroundColor = Colors.white, + this.inactiveBackgroundColor = Colors.white, + this.activeBorderColor, + this.inactiveBorderColor, + required this.value, + this.activeIcon = const Icon( + Symbols.check_rounded, + size: 14, + color: Color(0xff10DC60), + weight: 800, + ), + this.inactiveIcon, + this.customBgColor = const Color(0xff10DC60), + this.label, + required this.onChanged, + }) : super(key: key); + + final double size; + + final Color activeBackgroundColor; + + final Color inactiveBackgroundColor; + + final Color? activeBorderColor; + + final Color? inactiveBorderColor; + + final ValueChanged onChanged; + + final bool value; + + final Widget activeIcon; + + final Widget? inactiveIcon; + + final Color customBgColor; + + final String? label; + + @override + _TSimpleCheckboxState createState() => _TSimpleCheckboxState(); +} + +class _TSimpleCheckboxState extends State { + @override + Widget build(BuildContext context) { + Widget child = SizedBox( + height: widget.size, + width: widget.size, + child: DecoratedBox( + decoration: BoxDecoration( + color: widget.value + ? widget.activeBackgroundColor + : widget.inactiveBackgroundColor, + borderRadius: Sizes.borderRadiusCircular4, + border: Border.all( + color: widget.value + ? (widget.activeBorderColor ?? context.theme.dividerColor) + : (widget.inactiveBorderColor ?? + context.theme.dividerColor))), + child: widget.value ? widget.activeIcon : widget.inactiveIcon, + ), + ); + if (widget.label case final label?) { + child = Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [child, Text(label)], + ); + } + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + widget.onChanged(!widget.value); + }, + child: child, + ), + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_circle/t_circle.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_circle/t_circle.dart new file mode 100644 index 0000000000..e795e2aebb --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_circle/t_circle.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +class TCircle extends StatelessWidget { + const TCircle( + {super.key, + this.size = 18, + this.backgroundColor = Colors.blue, + required this.child}); + + final double size; + final Color backgroundColor; + final Widget child; + + @override + Widget build(BuildContext context) => SizedBox( + height: size, + width: size, + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + ), + child: Center( + child: child, + ), + ), + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_picker/t_date_cell.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_picker/t_date_cell.dart new file mode 100644 index 0000000000..bdf61a5570 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_picker/t_date_cell.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/view_models/date_format_view_models.dart'; +import '../../../themes/index.dart'; + +import '../index.dart'; + +enum RangePosition { + start, + end, + middle, + none, +} + +class TDateCell extends ConsumerStatefulWidget { + TDateCell( + {Key? key, + required this.date, + required this.isToday, + required this.inCurrentCalendarMonth, + required this.selectRangePosition, + required this.disableRangePosition, + required this.onTap, + this.onMouseRegionEntered, + this.onMouseRegionExited}) + : day = date.day.toString(), + super(key: key); + + final DateTime date; + final String day; + final bool isToday; + final bool inCurrentCalendarMonth; + final RangePosition selectRangePosition; + + final RangePosition disableRangePosition; + final ValueChanged onTap; + final ValueChanged? onMouseRegionEntered; + final ValueChanged? onMouseRegionExited; + + @override + ConsumerState createState() => _TDateCellState(); +} + +class _TDateCellState extends ConsumerState { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final theme = context.theme; + Widget child; + final disabled = widget.disableRangePosition != RangePosition.none; + if (disabled) { + child = Row( + children: [ + Expanded( + child: SizedBox( + height: 24, + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color.fromARGB(255, 245, 245, 245), + borderRadius: switch (widget.disableRangePosition) { + RangePosition.middle => null, + RangePosition.start => + const BorderRadius.horizontal(left: Radius.circular(4)), + RangePosition.end => const BorderRadius.horizontal( + right: Radius.circular(4)), + RangePosition.none => + throw AssertionError('Should not reach here'), + }), + child: SizedBox( + width: 24, + height: 24, + child: DecoratedBox( + decoration: BoxDecoration( + border: widget.isToday + ? Border.all(color: theme.disabledColor) + : null, + borderRadius: Sizes.borderRadiusCircular4, + ), + child: Center( + child: Text(widget.day, + style: TextStyle(color: theme.disabledColor))), + ), + ), + ), + ), + ), + ], + ); + } else if (!widget.inCurrentCalendarMonth) { + child = Row( + children: [ + Expanded( + child: SizedBox( + height: 24, + child: DecoratedBox( + decoration: const BoxDecoration(), + child: UnconstrainedBox( + child: SizedBox( + width: 24, + height: 24, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: Sizes.borderRadiusCircular4, + color: _isHovered + ? const Color.fromARGB(255, 245, 245, 245) + : null, + ), + child: Center( + child: Text( + widget.day, + style: const TextStyle( + color: Colors.black26, + ), + )))), + ), + ), + ), + ), + ], + ); + } else { + const colorSelectionRangeMiddle = Color.fromARGB(255, 230, 244, 255); + child = Row( + children: [ + Expanded( + child: SizedBox( + height: 24, + child: DecoratedBox( + decoration: switch (widget.selectRangePosition) { + RangePosition.none => const BoxDecoration(), + RangePosition.middle => const BoxDecoration( + color: colorSelectionRangeMiddle, + ), + RangePosition.start => const BoxDecoration( + gradient: LinearGradient( + stops: [0.5, 0.5], + colors: [Colors.transparent, colorSelectionRangeMiddle], + ), + borderRadius: + BorderRadius.horizontal(left: Radius.circular(4))), + RangePosition.end => const BoxDecoration( + gradient: LinearGradient( + stops: [0.5, 0.5], + colors: [colorSelectionRangeMiddle, Colors.transparent], + ), + borderRadius: + BorderRadius.horizontal(right: Radius.circular(4))) + }, + child: UnconstrainedBox( + child: SizedBox( + width: 24, + height: 24, + child: DecoratedBox( + decoration: BoxDecoration( + border: widget.isToday + ? Border.all(color: theme.primaryColor) + : null, + borderRadius: switch (widget.selectRangePosition) { + RangePosition.middle => null, + RangePosition.start => const BorderRadius.horizontal( + left: Radius.circular(4)), + RangePosition.end => const BorderRadius.horizontal( + right: Radius.circular(4)), + RangePosition.none => Sizes.borderRadiusCircular4, + }, + color: _isHovered + ? theme.primaryColor + : switch (widget.selectRangePosition) { + RangePosition.none => null, + RangePosition.middle => + const Color.fromARGB(255, 230, 244, 255), + RangePosition.start => theme.primaryColor, + RangePosition.end => theme.primaryColor, + }, + ), + child: Center( + child: Text(widget.day, + style: TextStyle( + color: _isHovered + ? Colors.white + : switch (widget.selectRangePosition) { + RangePosition.none => null, + RangePosition.middle => null, + RangePosition.start => Colors.white, + RangePosition.end => Colors.white, + }))), + ), + ), + ), + ), + ), + ), + ], + ); + } + return MouseRegion( + cursor: + disabled ? SystemMouseCursors.forbidden : SystemMouseCursors.click, + onEnter: (event) { + if (!disabled) { + setState(() { + _isHovered = true; + }); + if (widget.inCurrentCalendarMonth) { + widget.onMouseRegionEntered?.call(widget.date); + } + } + }, + onExit: (event) { + if (!disabled) { + setState(() { + _isHovered = false; + }); + if (widget.inCurrentCalendarMonth) { + widget.onMouseRegionExited?.call(widget.date); + } + } + }, + child: GestureDetector( + onTap: () { + if (!disabled) { + widget.onTap(widget.date); + } + }, + child: TTooltip( + message: ref.watch(dateFormatViewModel_yMd).format(widget.date), + waitDuration: const Duration(milliseconds: 1000), + child: child, + ), + ), + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_picker/t_date_picker.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_picker/t_date_picker.dart new file mode 100644 index 0000000000..1aac195ea2 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_picker/t_date_picker.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +import '../../../../infra/time/datetime_utils.dart'; +import '../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../themes/index.dart'; +import '../index.dart'; +import 't_date_cell.dart'; + +class TDatePicker extends ConsumerWidget { + const TDatePicker( + {Key? key, + required this.calendarDate, + this.availableStartDate, + this.availableEndDate, + this.selectedStartDate, + this.selectedEndDate, + this.hoveredStartDate, + this.hoveredEndDate, + this.showPrevButtons = true, + this.showNextButtons = true, + this.onCalendarDateChanged, + this.onDateChanged, + this.onMouseRegionEntered, + this.onMouseRegionExited}) + : super(key: key); + + final DateTime calendarDate; + final DateTime? availableStartDate; + final DateTime? availableEndDate; + final DateTime? selectedStartDate; + final DateTime? selectedEndDate; + final DateTime? hoveredStartDate; + final DateTime? hoveredEndDate; + final bool showPrevButtons; + final bool showNextButtons; + + final ValueChanged? onCalendarDateChanged; + final ValueChanged? onDateChanged; + final ValueChanged? onMouseRegionEntered; + final ValueChanged? onMouseRegionExited; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final localeName = ref.watch(appLocalizationsViewModel).localeName; + final dateSymbols = DateFormat.EEEE(localeName).dateSymbols; + final weekdays = dateSymbols.NARROWWEEKDAYS; + final dateStr = DateFormat.yM(localeName).format(calendarDate); + final thisMonthFirstDay = DateTime(calendarDate.year, calendarDate.month); + final mostRecentWeekday = DateTimeUtils.getMostRecentWeekday( + thisMonthFirstDay, dateSymbols.FIRSTDAYOFWEEK + 1); + return Column( + children: [ + _buildTitle(dateStr), + const THorizontalDivider(), + _buildBody(weekdays, mostRecentWeekday) + ], + ); + } + + Widget _buildTitle(String dateStr) => Padding( + padding: Sizes.paddingV4H4, + child: Row(children: [ + if (showPrevButtons) + TIconButton( + iconData: Symbols.keyboard_double_arrow_left_rounded, + iconColor: Colors.black45, + iconColorHovered: Colors.black, + addContainer: false, + onTap: () => onCalendarDateChanged?.call( + DateTime(calendarDate.year - 1, calendarDate.month), + ), + ), + if (showPrevButtons) + TIconButton( + iconData: Symbols.keyboard_arrow_left_rounded, + iconColor: Colors.black45, + iconColorHovered: Colors.black, + addContainer: false, + onTap: () => onCalendarDateChanged?.call( + DateTime(calendarDate.year, calendarDate.month - 1), + ), + ), + Expanded( + child: Text( + dateStr, + textAlign: TextAlign.center, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + if (showNextButtons) + TIconButton( + iconData: Symbols.keyboard_arrow_right_rounded, + iconColor: Colors.black45, + iconColorHovered: Colors.black, + addContainer: false, + onTap: () => onCalendarDateChanged?.call( + DateTime(calendarDate.year, calendarDate.month + 1), + ), + ), + if (showNextButtons) + TIconButton( + iconData: Symbols.keyboard_double_arrow_right_rounded, + iconColor: Colors.black45, + iconColorHovered: Colors.black, + addContainer: false, + onTap: () => onCalendarDateChanged?.call( + DateTime(calendarDate.year + 1, calendarDate.month), + ), + ), + ]), + ); + + Expanded _buildBody(List weekdays, DateTime firstDay) { + final now = DateTime.now(); + final children = [ + for (final weekday in weekdays) + Center( + child: Text( + weekday, + ), + ), + ]; + DateTime date; + final localAvailableStartDate = availableStartDate; + final localAvailableEndDate = availableEndDate; + final startDate = hoveredStartDate ?? selectedStartDate; + final endDate = hoveredEndDate ?? selectedEndDate; + for (var i = 0; i < DateTime.daysPerWeek * 6; i++) { + date = DateTime(firstDay.year, firstDay.month, firstDay.day + i); + children.add(TDateCell( + date: date, + isToday: DateUtils.isSameDay(date, now), + inCurrentCalendarMonth: DateUtils.isSameMonth(date, calendarDate), + selectRangePosition: _getSelectRangePosition(date, startDate, endDate), + // hoverRangePosition: _getSelectRangePosition( + // date, localHoveredStartDate, localHoveredEndDate), + disableRangePosition: _getDisableRangePosition( + date, localAvailableStartDate, localAvailableEndDate), + onTap: (DateTime value) { + onDateChanged?.call(value); + }, + onMouseRegionEntered: onMouseRegionEntered, + onMouseRegionExited: onMouseRegionExited, + )); + } + return Expanded( + child: Padding( + padding: Sizes.paddingH16, + child: GridView.count( + crossAxisCount: DateTime.daysPerWeek, + children: children, + ), + ), + ); + } + + RangePosition _getSelectRangePosition( + DateTime date, DateTime? startDate, DateTime? endDate) { + if (DateUtils.isSameDay(date, startDate)) { + return RangePosition.start; + } + if (DateUtils.isSameDay(date, endDate)) { + return RangePosition.end; + } + return DateTimeUtils.isBetween(date, startDate, endDate) + ? RangePosition.middle + : RangePosition.none; + } + + RangePosition _getDisableRangePosition( + DateTime date, DateTime? availableStartDate, DateTime? availableEndDate) { + if (availableStartDate != null) { + if (DateUtils.isSameDay( + date, availableStartDate.subtract(const Duration(days: 1)))) { + return RangePosition.end; + } else if (date.isBefore(availableStartDate)) { + return RangePosition.middle; + } + } + if (availableEndDate != null) { + if (DateUtils.isSameDay( + date, availableEndDate.add(const Duration(days: 1)))) { + return RangePosition.start; + } else if (date.isAfter(availableEndDate)) { + return RangePosition.middle; + } + } + return RangePosition.none; + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_range_picker/t_date_range_input.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_range_picker/t_date_range_input.dart new file mode 100644 index 0000000000..5f89e073bc --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_range_picker/t_date_range_input.dart @@ -0,0 +1,108 @@ +part of 't_date_range_picker.dart'; + +class _TDateRangeInput extends ConsumerStatefulWidget { + const _TDateRangeInput( + {Key? key, + this.startDate, + this.endDate, + this.previewStartDate, + this.previewEndDate, + required this.startDateFocusNode, + required this.endDateFocusNode}) + : super(key: key); + + final DateTime? startDate; + final DateTime? endDate; + final DateTime? previewStartDate; + final DateTime? previewEndDate; + final FocusNode startDateFocusNode; + final FocusNode endDateFocusNode; + + @override + ConsumerState<_TDateRangeInput> createState() => _TDateRangeInputState(); +} + +class _TDateRangeInputState extends ConsumerState<_TDateRangeInput> { + late TextEditingController _startDateInputController; + late TextEditingController _endDateInputController; + + @override + void initState() { + super.initState(); + + _startDateInputController = TextEditingController(); + _endDateInputController = TextEditingController(); + } + + @override + void dispose() { + _startDateInputController.dispose(); + _endDateInputController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final appThemeExtension = context.appThemeExtension; + final previewStartDate = widget.previewStartDate; + final previewEndDate = widget.previewEndDate; + final usePreviewStartDate = previewStartDate != null; + final usePreviewEndDate = previewEndDate != null; + final DateTime? startDate; + if (usePreviewStartDate) { + startDate = previewStartDate; + } else { + startDate = widget.startDate; + } + final DateTime? endDate; + if (usePreviewEndDate) { + endDate = previewEndDate; + } else { + endDate = widget.endDate; + } + + final dateFormat = ref.read(dateFormatViewModel_yMd); + _startDateInputController.text = + startDate == null ? '' : dateFormat.format(startDate); + _endDateInputController.text = + endDate == null ? '' : dateFormat.format(endDate); + final textStyle = + appThemeExtension.descriptionTextStyle.copyWith(height: 1); + + return Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + IntrinsicWidth( + child: TTextField( + textEditingController: _startDateInputController, + focusNode: widget.startDateFocusNode, + readOnly: true, + showCursor: false, + textAlign: TextAlign.center, + style: textStyle, + onTapOutside: _onTapOutside, + ), + ), + const Icon(Symbols.arrow_forward_rounded, size: 16), + IntrinsicWidth( + child: TTextField( + textEditingController: _endDateInputController, + focusNode: widget.endDateFocusNode, + readOnly: true, + showCursor: false, + textAlign: TextAlign.center, + style: textStyle, + onTapOutside: _onTapOutside, + ), + ), + ], + ); + } + + void _onTapOutside(_) { + // it will unfocus by default, + // we don't want to unfocus here, + // so do nothing. + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_range_picker/t_date_range_picker.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_range_picker/t_date_range_picker.dart new file mode 100644 index 0000000000..b747d41bc4 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_range_picker/t_date_range_picker.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../l10n/view_models/date_format_view_models.dart'; +import '../../../themes/index.dart'; + +import '../index.dart'; + +part 't_date_range_input.dart'; + +part 't_date_range_picker_panel.dart'; + +class TDateRangePicker extends StatefulWidget { + TDateRangePicker( + {Key? key, + required this.firstDate, + required this.lastDate, + required this.initialDateRange}) + : super(key: key); + + final DateTime firstDate; + final DateTime lastDate; + final DateTimeRange initialDateRange; + + final _popupController = TPopupController(); + + @override + State createState() => _TDateRangePickerState(); +} + +const _dateRangePickerGroupId = 'dateRangePicker'; + +class _TDateRangePickerState extends State { + late FocusNode _startDateFocusNode; + late FocusNode _endDateFocusNode; + + DateTime? _selectedStartDate; + DateTime? _selectedEndDate; + + DateTime? _hoveredStartDate; + DateTime? _hoveredEndDate; + + @override + void initState() { + super.initState(); + _startDateFocusNode = FocusNode()..addListener(_onFocusChanged); + _endDateFocusNode = FocusNode()..addListener(_onFocusChanged); + _selectedStartDate = widget.initialDateRange.start; + _selectedEndDate = widget.initialDateRange.end; + } + + @override + void dispose() { + _startDateFocusNode.dispose(); + _endDateFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => TPopup( + controller: widget._popupController, + targetAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + offset: const Offset(0, 4), + target: TapRegion( + groupId: _dateRangePickerGroupId, + child: _TDateRangeInput( + startDate: _selectedStartDate, + startDateFocusNode: _startDateFocusNode, + previewStartDate: _hoveredStartDate, + endDate: _selectedEndDate, + endDateFocusNode: _endDateFocusNode, + previewEndDate: _hoveredEndDate, + ), + ), + follower: TapRegion( + groupId: _dateRangePickerGroupId, + child: _TDateRangePickerPanel( + availableStartDate: widget.firstDate, + availableEndDate: widget.lastDate, + hoveredStartDate: _hoveredStartDate, + hoveredEndDate: _hoveredEndDate, + initialDateRange: widget.initialDateRange, + onDateChanged: (DateTime value) { + if (_startDateFocusNode.hasFocus) { + _selectedStartDate = value; + if (_selectedEndDate != null && + value.isAfter(_selectedEndDate!)) { + _selectedEndDate = null; + _endDateFocusNode.requestFocus(); + } else { + widget._popupController.hidePopover?.call(); + } + } else { + _selectedEndDate = value; + if (_selectedStartDate != null && + value.isBefore(_selectedStartDate!)) { + _selectedStartDate = null; + _startDateFocusNode.requestFocus(); + } else { + widget._popupController.hidePopover?.call(); + } + } + setState(() {}); + }, + onMouseRegionEntered: (DateTime value) { + if (_startDateFocusNode.hasFocus) { + _hoveredStartDate = value; + } else { + _hoveredEndDate = value; + } + setState(() {}); + }, + onMouseRegionExited: (DateTime value) { + if (_startDateFocusNode.hasFocus) { + if (_hoveredStartDate == value) { + _hoveredStartDate = null; + } + } else { + if (_hoveredEndDate == value) { + _hoveredEndDate = null; + } + } + setState(() {}); + }, + ), + ), + onDismissed: () { + _startDateFocusNode.unfocus(); + _endDateFocusNode.unfocus(); + }, + ); + + void _onFocusChanged() { + if (_startDateFocusNode.hasFocus || _endDateFocusNode.hasFocus) { + widget._popupController.showPopover?.call(); + } else { + widget._popupController.hidePopover?.call(); + } + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_range_picker/t_date_range_picker_panel.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_range_picker/t_date_range_picker_panel.dart new file mode 100644 index 0000000000..80e0dac28d --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_date_range_picker/t_date_range_picker_panel.dart @@ -0,0 +1,91 @@ +part of 't_date_range_picker.dart'; + +class _TDateRangePickerPanel extends StatefulWidget { + const _TDateRangePickerPanel({ + Key? key, + required this.availableStartDate, + required this.availableEndDate, + required this.hoveredStartDate, + required this.hoveredEndDate, + required this.initialDateRange, + required this.onDateChanged, + required this.onMouseRegionEntered, + required this.onMouseRegionExited, + }) : super(key: key); + + final DateTime availableStartDate; + final DateTime availableEndDate; + final DateTimeRange initialDateRange; + final DateTime? hoveredStartDate; + final DateTime? hoveredEndDate; + + final ValueChanged onDateChanged; + final ValueChanged onMouseRegionEntered; + final ValueChanged onMouseRegionExited; + + @override + State<_TDateRangePickerPanel> createState() => _TDateRangePickerPanelState(); +} + +class _TDateRangePickerPanelState extends State<_TDateRangePickerPanel> { + late DateTime _calenderDate; + late DateTime _selectedStartDate; + late DateTime _selectedEndDate; + + @override + void initState() { + super.initState(); + final initialDateRange = widget.initialDateRange; + _calenderDate = initialDateRange.start; + _selectedStartDate = initialDateRange.start; + _selectedEndDate = initialDateRange.end; + } + + @override + Widget build(BuildContext context) => Material( + child: SizedBox( + width: Sizes.dateRangePickerWidth, + height: Sizes.dateRangePickerHeight, + child: DecoratedBox( + decoration: context.appThemeExtension.popupDecoration, + child: Row( + children: [ + Expanded( + child: TDatePicker( + calendarDate: _calenderDate, + availableStartDate: widget.availableStartDate, + availableEndDate: widget.availableEndDate, + selectedStartDate: _selectedStartDate, + selectedEndDate: _selectedEndDate, + hoveredStartDate: widget.hoveredStartDate, + hoveredEndDate: widget.hoveredEndDate, + showNextButtons: false, + onCalendarDateChanged: (value) => + setState(() => _calenderDate = value), + onDateChanged: widget.onDateChanged, + onMouseRegionEntered: widget.onMouseRegionEntered, + onMouseRegionExited: widget.onMouseRegionExited, + )), + Expanded( + child: TDatePicker( + calendarDate: + DateUtils.addMonthsToMonthDate(_calenderDate, 1), + availableStartDate: widget.availableStartDate, + availableEndDate: widget.availableEndDate, + selectedStartDate: _selectedStartDate, + selectedEndDate: _selectedEndDate, + hoveredStartDate: widget.hoveredStartDate, + hoveredEndDate: widget.hoveredEndDate, + showPrevButtons: false, + onCalendarDateChanged: (value) => setState(() => + _calenderDate = DateTime(value.year, value.month - 1)), + onDateChanged: widget.onDateChanged, + onMouseRegionEntered: widget.onMouseRegionEntered, + onMouseRegionExited: widget.onMouseRegionExited, + )) + ], + ), + ), + ), + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_dialog/t_dialog.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_dialog/t_dialog.dart new file mode 100644 index 0000000000..6266d8ae68 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_dialog/t_dialog.dart @@ -0,0 +1,145 @@ +import 'package:animations/animations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/app_localizations.dart'; +import '../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../themes/index.dart'; + +import '../t_button/t_text_button.dart'; +import '../t_title_bar/t_title_bar.dart'; + +const config = FadeScaleTransitionConfiguration( + barrierColor: Colors.transparent, + barrierDismissible: false, +); + +const _routeSettingsArguments = Object(); + +bool isTDialogRoute(Route route) => + route.settings.arguments == _routeSettingsArguments; + +Future showCustomTDialog( + {required String routeName, + required BuildContext context, + BorderRadiusGeometry borderRadius = Sizes.borderRadiusCircular4, + required Widget child}) => + showModal( + routeSettings: + RouteSettings(name: routeName, arguments: _routeSettingsArguments), + context: context, + configuration: config, + builder: (BuildContext context) => Align( + child: Material( + color: Colors.transparent, + borderRadius: borderRadius, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: borderRadius, + boxShadow: Styles.boxShadow), + child: ClipRRect( + borderRadius: borderRadius, + child: RepaintBoundary(child: child))), + ), + )); + +Future showSimpleTDialog( + {required String routeName, + required BuildContext context, + double? width, + double? height, + required Widget child}) => + showCustomTDialog( + context: context, + routeName: routeName, + child: Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? _) => SizedBox( + width: width ?? Sizes.dialogWidthMedium, + height: height ?? Sizes.dialogHeightMedium, + child: Stack( + children: [ + Positioned.fill( + child: child, + ), + const TTitleBar( + displayCloseOnly: true, + popOnCloseTapped: true, + ) + ], + ), + ), + )); + +Future showAlertTDialog( + {required String routeName, + required BuildContext context, + required String Function(AppLocalizations) contentTextProvider, + required TDialogAction confirmAction}) => + showCustomTDialog( + context: context, + routeName: routeName, + child: Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? _) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + final theme = context.theme; + final appThemeExtension = theme.appThemeExtension; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text(contentTextProvider(appLocalizations)), + Sizes.sizedBoxH16, + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + TTextButton( + containerWidth: 112, + containerHeight: 28, + onTap: () { + if (confirmAction.onPressed()) { + Navigator.of(context).pop(); + } + }, + containerColor: + confirmAction.style == TDialogActionStyle.danger + ? appThemeExtension.dangerColor + : null, + textStyle: + confirmAction.style == TDialogActionStyle.danger + ? theme.textTheme.bodyMedium! + .copyWith(color: Colors.white) + : null, + text: confirmAction.textProvider!(appLocalizations), + ), + Sizes.sizedBoxW16, + TTextButton.outlined( + containerWidth: 112, + containerHeight: 28, + theme: theme, + onTap: () => Navigator.of(context).pop(), + text: appLocalizations.cancel, + ) + ], + ) + ], + ), + ); + }, + )); + +class TDialogAction { + const TDialogAction({this.style, this.textProvider, required this.onPressed}); + + final TDialogActionStyle? style; + final String Function(AppLocalizations)? textProvider; + final bool Function() onPressed; +} + +enum TDialogActionStyle { + primary, + danger, +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_divider/t_horizontal_divider.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_divider/t_horizontal_divider.dart new file mode 100644 index 0000000000..8bd1f610a5 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_divider/t_horizontal_divider.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/index.dart'; + +class THorizontalDivider extends StatelessWidget { + const THorizontalDivider({Key? key, this.color, this.thickness = 1.0}) + : super(key: key); + + final Color? color; + final double thickness; + + @override + Widget build(BuildContext context) => SizedBox( + width: double.infinity, + height: thickness, + child: DecoratedBox( + decoration: BoxDecoration( + color: color ?? context.theme.dividerColor, + ))); +} + +class TMovableHorizontalDivider extends StatefulWidget { + const TMovableHorizontalDivider( + {super.key, this.color, this.onMove, required this.onMoved}); + + final Color? color; + final VoidCallback? onMove; + final ValueChanged onMoved; + + @override + State createState() => + _TMovableHorizontalDividerState(); +} + +class _TMovableHorizontalDividerState extends State { + bool _isResizing = false; + double _dyOnPointerDown = 0; + + @override + Widget build(BuildContext context) { + final theme = context.theme; + return Listener( + onPointerCancel: (event) { + _isResizing = false; + setState(() {}); + }, + onPointerUp: (event) { + _isResizing = false; + setState(() {}); + }, + onPointerDown: (PointerDownEvent event) { + widget.onMove?.call(); + _dyOnPointerDown = event.position.dy; + _isResizing = true; + setState(() {}); + }, + onPointerMove: (event) { + final delta = event.position.dy - _dyOnPointerDown; + widget.onMoved(delta); + }, + child: MouseRegion( + cursor: SystemMouseCursors.resizeUpDown, + child: _isResizing + ? Padding( + padding: Sizes.paddingV2, + child: THorizontalDivider( + color: theme.primaryColor, + thickness: 5, + ), + ) + : Padding( + padding: Sizes.paddingV4, + child: THorizontalDivider( + color: widget.color, + ), + ), + ), + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_divider/t_vertical_divider.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_divider/t_vertical_divider.dart new file mode 100644 index 0000000000..c19a0b88e6 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_divider/t_vertical_divider.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/index.dart'; + +class TVerticalDivider extends StatelessWidget { + const TVerticalDivider({Key? key, this.color, this.thickness = 1.0}) + : super(key: key); + + final Color? color; + final double thickness; + + @override + Widget build(BuildContext context) => SizedBox( + width: thickness, + height: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + color: color ?? context.theme.dividerColor, + ))); +} + +class TMovableVerticalDivider extends StatefulWidget { + const TMovableVerticalDivider( + {super.key, + this.color, + this.size = TMovableVerticalDividerSize.medium, + this.onMove, + required this.onMoved}); + + final Color? color; + final TMovableVerticalDividerSize size; + final VoidCallback? onMove; + final ValueChanged onMoved; + + @override + State createState() => + _TMovableVerticalDividerState(); +} + +class _TMovableVerticalDividerState extends State { + bool _isResizing = false; + double _dxOnPointerDown = 0; + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final size = widget.size; + return Listener( + onPointerCancel: (event) { + _isResizing = false; + setState(() {}); + }, + onPointerUp: (event) { + _isResizing = false; + setState(() {}); + }, + onPointerDown: (PointerDownEvent event) { + widget.onMove?.call(); + _dxOnPointerDown = event.position.dx; + _isResizing = true; + setState(() {}); + }, + onPointerMove: (event) { + final delta = event.position.dx - _dxOnPointerDown; + widget.onMoved(delta); + }, + child: MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: _isResizing + ? Padding( + padding: size.paddingOnResizing, + child: TVerticalDivider( + color: theme.primaryColor, + thickness: size.thicknessOnResizing, + ), + ) + : Padding( + padding: size.padding, + child: TVerticalDivider( + color: widget.color, + thickness: size.thickness, + ), + ), + ), + ); + } +} + +enum TMovableVerticalDividerSize { + medium(Sizes.paddingH4, 1.0, Sizes.paddingH2, 5.0), + small(Sizes.paddingH2, 1.0, Sizes.paddingH1, 3.0); + + const TMovableVerticalDividerSize(this.padding, this.thickness, + this.paddingOnResizing, this.thicknessOnResizing); + + final EdgeInsets padding; + final double thickness; + + final EdgeInsets paddingOnResizing; + final double thicknessOnResizing; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_drawer/t_drawer.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_drawer/t_drawer.dart new file mode 100644 index 0000000000..cb95b0e4c4 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_drawer/t_drawer.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +import '../../../../infra/animation/animation_extensions.dart'; + +void showTDrawer(BuildContext context, Widget child) => + Navigator.of(context).push(_TDrawerRoute(child)); + +class TDrawer extends StatefulWidget { + const TDrawer({super.key, this.controller, required this.child}); + + final TDrawerController? controller; + final Widget child; + + @override + State createState() => _TDrawerState(); +} + +class _TDrawerState extends State with SingleTickerProviderStateMixin { + late Widget _currentChild; + Widget? _nextChild; + late AnimationController _animationController; + + @override + void initState() { + super.initState(); + _currentChild = widget.child; + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + + final controller = widget.controller; + controller?.toggle = toggle; + controller?.show = show; + controller?.hide = hide; + } + + @override + void dispose() { + final controller = widget.controller; + controller?.toggle = null; + controller?.show = null; + controller?.hide = null; + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => _TDrawerView( + animation: _animationController, + child: _currentChild, + ); + + @override + void didUpdateWidget(TDrawer oldWidget) { + super.didUpdateWidget(oldWidget); + final newController = widget.controller; + newController?.toggle = toggle; + newController?.show = show; + newController?.hide = hide; + // Only change to show the next child + // if the drawer is not visible, + // or the drawer is hidden and show again. + if (_animationController.status.isNotDismissed) { + _nextChild = widget.child; + } else { + _nextChild = null; + _currentChild = widget.child; + } + } + + void toggle() { + if (_animationController.status.isDismissed) { + show(); + } else { + hide(); + } + } + + void show() { + _animationController.forward(); + if (_nextChild case final nextChild?) { + _currentChild = nextChild; + _nextChild = null; + setState(() {}); + } + } + + void hide() { + _animationController.reverse(); + } +} + +class _TDrawerView extends StatelessWidget { + const _TDrawerView({required this.animation, required this.child}); + + final Animation animation; + final Widget child; + + @override + Widget build(BuildContext context) => Align( + alignment: Alignment.centerRight, + child: SizeTransition( + axis: Axis.horizontal, + sizeFactor: animation.drive(Tween(begin: 0, end: 1) + .chain(CurveTween(curve: Curves.fastOutSlowIn))), + child: child, + ), + ); +} + +class _TDrawerRoute extends PopupRoute { + _TDrawerRoute(this.child); + + final Widget child; + + @override + Color? get barrierColor => Colors.transparent; + + @override + bool get barrierDismissible => true; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => const Duration(milliseconds: 200); + + @override + Widget buildPage(BuildContext context, Animation animation, + Animation secondaryAnimation) => + RepaintBoundary( + child: Material( + color: Colors.transparent, + child: _TDrawerView( + animation: animation, + child: child, + ), + ), + ); +} + +class TDrawerController { + void Function()? toggle; + void Function()? show; + void Function()? hide; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_empty/t_empty.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_empty/t_empty.dart new file mode 100644 index 0000000000..ee1ed38cb5 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_empty/t_empty.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../../../infra/assets/assets.gen.dart'; + +class TEmpty extends StatelessWidget { + const TEmpty({super.key}); + + @override + Widget build(BuildContext context) => Center( + child: Opacity( + opacity: 0.2, + child: ColorFiltered( + // greyscale color filter + colorFilter: const ColorFilter.matrix([ + 0.2126, 0.7152, 0.0722, 0, 0, // + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0, 0, 0, 1, 0, + ]), + child: SvgPicture.asset( + width: 100, + Assets.images.iconSvg, + ), + ), + )); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_empty/t_empty_result.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_empty/t_empty_result.dart new file mode 100644 index 0000000000..bd4126ebf6 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_empty/t_empty_result.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../themes/index.dart'; + +const _colorFilter = ColorFilter.matrix([ + 0.2126, 0.7152, 0.0722, 0, 0, // + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0, 0, 0, 1, 0, +]); + +class TEmptyResult extends ConsumerWidget { + const TEmptyResult({super.key, this.icon = Symbols.description_rounded}); + + final IconData icon; + + @override + Widget build(BuildContext context, WidgetRef ref) => Center( + child: ColorFiltered( + colorFilter: _colorFilter, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Opacity( + opacity: 0.1, + child: Icon( + icon, + size: 100, + ), + ), + const Positioned( + left: 40, + top: 40, + child: Opacity( + opacity: 0.5, + child: Icon( + Symbols.search_rounded, + size: 80, + ), + ), + ), + ], + ), + Sizes.sizedBoxH16, + Text(ref.watch(appLocalizationsViewModel).noResultsFound, + style: context.appThemeExtension.descriptionTextStyle + .copyWith(fontSize: 18)), + Sizes.sizedBoxH32, + ], + ), + )); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_focus_tracker/t_focus_tracker.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_focus_tracker/t_focus_tracker.dart new file mode 100644 index 0000000000..5ab4755e53 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_focus_tracker/t_focus_tracker.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/index.dart'; + +class TFocusTracker extends StatefulWidget { + const TFocusTracker({Key? key, required this.child}) : super(key: key); + + final Widget child; + + @override + State createState() => _TFocusTrackerState(); +} + +class _TFocusTrackerState extends State { + static OverlayState? _overlayState; + static OverlayEntry? _overlayEntry; + + @override + void initState() { + super.initState(); + FocusManager.instance.addListener(_onFocusChanged); + } + + @override + void dispose() { + FocusManager.instance.removeListener(_onFocusChanged); + _removeOverlayEntry(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; + + void _removeOverlayEntry() { + if (_overlayEntry case final overlayEntry?) { + overlayEntry.remove(); + _overlayEntry = null; + } + } + + void _onFocusChanged() { + final focus = FocusManager.instance.primaryFocus; + if (focus == null || focus.context == null) { + _removeOverlayEntry(); + return; + } + final rect = focus.rect; + _removeOverlayEntry(); + _overlayState = Overlay.of(context); + _overlayEntry = OverlayEntry( + builder: (BuildContext context) => + _buildOverlayEntry(context, focus, rect)); + _overlayState!.insert(_overlayEntry!); + } + + Widget _buildOverlayEntry(BuildContext context, FocusNode focus, Rect rect) { + final parentFocusDebugLabel = focus.parent?.debugLabel ?? + focus.parent?.context?.widget.runtimeType.toString() ?? + ''; + final focusDebugLabel = + focus.debugLabel ?? focus.context?.widget.runtimeType.toString() ?? ''; + return Positioned( + left: rect.left, + top: rect.top, + child: IgnorePointer( + child: UnconstrainedBox( + child: Stack( + children: [ + SizedBox( + width: rect.width, + height: rect.height, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: Sizes.borderRadiusCircular4, + color: Colors.black.withValues(alpha: 0.3), + ), + child: const SizedBox.shrink(), + )), + Positioned.fill( + // The text rect size maybe larger than the box, + // so we allow the text overflow. + child: OverflowBox( + maxWidth: double.infinity, + maxHeight: double.infinity, + child: Center( + child: Text( + 'Parent Focus: $parentFocusDebugLabel\nCurrent Focus: $focusDebugLabel', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Colors.red, fontWeight: FontWeight.w700)), + ), + ), + ) + ], + ), + ), + ), + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_form/t_form.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_form/t_form.dart new file mode 100644 index 0000000000..5aca2a0e0c --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_form/t_form.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../themes/index.dart'; +import '../index.dart'; + +class TForm extends StatelessWidget { + const TForm({super.key, required this.formData}); + + final TFormData formData; + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: formData.groups.indexed.expand((item) { + final (index, group) = item; + final children = []; + if (index > 0) { + children.add(const Padding( + padding: Sizes.paddingV16, + // child: SizedBox.shrink(), + child: THorizontalDivider(), + )); + } + final text = Text(group.title, + key: group.titleKey, style: const TextStyle(fontSize: 16)); + final titleSuffix = group.titleSuffix; + if (titleSuffix == null) { + children.add(text); + } else { + children.add(Row(spacing: 16, children: [text, titleSuffix])); + } + for (final element in group.fields.indexed) { + final (index, field) = element; + for (final widget in _buildFormField(index, field)) { + children.add(widget); + } + } + return children; + }).toList(), + ); + + List _buildFormField(int index, TFormField field) => + [Sizes.sizedBoxH8] + + switch (field) { + TFormFieldCheckbox() => [ + TSimpleCheckbox( + onChanged: field.onChanged, + value: field.value, + label: field.label) + ], + TFormFieldRadioGroup() => [ + Wrap( + direction: Axis.vertical, + spacing: 8, + children: [ + Text(field.label), + Wrap(direction: Axis.vertical, spacing: 8, children: [ + for (final radio in field.radios) + TRadio( + value: radio.value, + groupValue: field.groupValue, + label: radio.label, + onChanged: (value) { + // TODO: Wait for "declaration-site variance" + // https://github.com/dart-lang/language/issues/524 + (radio as dynamic)?.onChanged?.call(value); + (field as dynamic)?.onChanged?.call(value); + }, + ) + ]) + ], + ), + ], + TFormFieldShortcutTextField() => [ + Row(spacing: 16, mainAxisSize: MainAxisSize.min, children: [ + Text(field.label), + SizedBox( + width: 180, + child: TShortcutTextField( + initialKeys: field.initialKeys, + onShortcutChanged: field.onShortcutChanged, + ), + ) + ]) + ], + TFormFieldSelect() => [ + Row(mainAxisSize: MainAxisSize.min, children: [ + Text(field.label), + const SizedBox( + width: 16, + ), + SizedBox( + width: 180, + child: TTextFieldMenuPopup( + value: field.value, + entries: field.entries, + onSelected: (TMenuEntry item) { + // TODO: Wait for "declaration-site variance" + // https://github.com/dart-lang/language/issues/524 + (field as dynamic).onSelected.call(item.value); + }, + ), + ) + ]) + ], + _ => [] + }; +} + +class TFormData { + TFormData({required this.groups}); + + final List groups; +} + +class TFormFieldGroup { + TFormFieldGroup( + {required this.title, + this.titleSuffix, + this.titleKey, + required this.fields}); + + final String title; + final Widget? titleSuffix; + final Key? titleKey; + final List fields; +} + +class TFormField { + TFormField({required this.label}); + + final String label; +} + +class TFormFieldCheckbox extends TFormField { + TFormFieldCheckbox( + {required super.label, required this.value, required this.onChanged}); + + final bool value; + + final ValueChanged onChanged; +} + +class TFormFieldRadioGroup extends TFormField { + TFormFieldRadioGroup( + {required super.label, + required this.groupValue, + required this.radios, + this.onChanged}); + + final T groupValue; + + final List> radios; + + final ValueChanged? onChanged; +} + +class TFormFieldRadio { + TFormFieldRadio({required this.label, required this.value, this.onChanged}); + + final String label; + + final T value; + + final ValueChanged? onChanged; +} + +class TFormFieldShortcutTextField extends TFormField { + TFormFieldShortcutTextField( + {required super.label, + this.initialKeys, + required this.onShortcutChanged}); + + final List? initialKeys; + final void Function(List keys) onShortcutChanged; +} + +class TFormFieldSelect extends TFormField { + TFormFieldSelect( + {required super.label, + this.value, + required this.entries, + required this.onSelected}); + + final T? value; + final List> entries; + final void Function(T value) onSelected; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_image/t_image_broken.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_image/t_image_broken.dart new file mode 100644 index 0000000000..f360e1bb56 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_image/t_image_broken.dart @@ -0,0 +1,15 @@ +import 'package:flutter/cupertino.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +class TImageBroken extends StatelessWidget { + const TImageBroken({super.key}); + + @override + Widget build(BuildContext context) => const DecoratedBox( + decoration: BoxDecoration(color: Color.fromARGB(255, 244, 244, 244)), + child: Icon( + Symbols.image_not_supported_rounded, + color: Color.fromARGB(255, 82, 82, 82), + ), + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_image/t_image_loading.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_image/t_image_loading.dart new file mode 100644 index 0000000000..d047b25cab --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_image/t_image_loading.dart @@ -0,0 +1,8 @@ +import 'package:flutter/cupertino.dart'; + +class TImageLoading extends StatelessWidget { + const TImageLoading({super.key}); + + @override + Widget build(BuildContext context) => const CupertinoActivityIndicator(); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_image_viewer/t_image_viewer.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_image_viewer/t_image_viewer.dart new file mode 100644 index 0000000000..69f548b859 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_image_viewer/t_image_viewer.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../../infra/env/env_vars.dart'; + +import '../t_dialog/t_dialog.dart'; +import '../t_title_bar/t_title_bar.dart'; + +const _width = 720.0; +const _height = 540.0; + +class TImageViewer extends StatefulWidget { + const TImageViewer({Key? key, required this.image}) : super(key: key); + + final ImageProvider image; + + @override + State createState() => _TImageViewerState(); +} + +class _TImageViewerState extends State { + @override + void dispose() { + final image = widget.image; + image.obtainKey(ImageConfiguration.empty).then((key) { + if (PaintingBinding.instance.imageCache.containsKey(key)) { + Timer(const Duration(seconds: 10), () { + PaintingBinding.instance.imageCache.evict(key, includeLive: false); + }); + } + }); + super.dispose(); + } + + @override + Widget build(BuildContext context) => SizedBox( + width: _width, + height: _height, + child: Column( + children: [ + const Align( + alignment: Alignment.topRight, + child: TTitleBar( + displayCloseOnly: true, + popOnCloseTapped: true, + usePositioned: false, + ), + ), + Expanded( + child: GestureDetector( + child: InteractiveViewer( + minScale: 0.25, + maxScale: 5, + scaleFactor: 500, + // TODO: use adaptive image size + child: Image( + image: ResizeImage(widget.image, + // We should NOT use the size of the view + // because we support zooming in the image viewer. + width: EnvVars.messageImageMaxCachedSizeWidth.toInt(), + height: EnvVars.messageImageMaxCachedSizeHeight.toInt(), + policy: ResizeImagePolicy.fit), + gaplessPlayback: true, + fit: BoxFit.contain, + isAntiAlias: true, + filterQuality: FilterQuality.high, + ), + ), + ), + ), + ], + )); +} + +Future showImageViewerDialog(BuildContext context, ImageProvider image) => + showCustomTDialog( + routeName: '/image-viewer-dialog', + context: context, + child: TImageViewer( + image: image, + )); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_layout/t_responsive_layout.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_layout/t_responsive_layout.dart new file mode 100644 index 0000000000..6eda59d8b0 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_layout/t_responsive_layout.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; + +class TResponsiveLayout extends StatelessWidget { + const TResponsiveLayout({ + Key? key, + required this.portraitLayoutContent, + required this.landscapeLayoutContent, + }) : super(key: key); + final Widget portraitLayoutContent; + final Widget landscapeLayoutContent; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth > 600) { + return landscapeLayoutContent; + } + return portraitLayoutContent; + }, + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_lazy_indexed_stack/t_lazy_indexed_stack.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_lazy_indexed_stack/t_lazy_indexed_stack.dart new file mode 100644 index 0000000000..8694ac86e8 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_lazy_indexed_stack/t_lazy_indexed_stack.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/sizes.dart'; + +class TLazyIndexedStack extends StatefulWidget { + const TLazyIndexedStack({ + super.key, + this.alignment = AlignmentDirectional.topStart, + this.textDirection, + this.sizing = StackFit.loose, + this.index = 0, + this.children = const [], + }); + + final AlignmentGeometry alignment; + final TextDirection? textDirection; + final StackFit sizing; + final int index; + final List children; + + @override + TLazyIndexedStackState createState() => TLazyIndexedStackState(); +} + +class TLazyIndexedStackState extends State { + late List _indexToActiveState = _initIndexToActiveStateList(); + + List _initIndexToActiveStateList() => + List.generate(widget.children.length, (i) => i == widget.index); + + @override + void didUpdateWidget(TLazyIndexedStack oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.children.length != widget.children.length) { + _indexToActiveState = _initIndexToActiveStateList(); + } + } + + @override + Widget build(BuildContext context) { + final indexToActiveState = _indexToActiveState; + final index = widget.index; + indexToActiveState[index] = true; + final children = List.generate(indexToActiveState.length, + (i) => indexToActiveState[i] ? widget.children[i] : Sizes.sizedBox0); + return IndexedStack( + alignment: widget.alignment, + sizing: widget.sizing, + textDirection: widget.textDirection, + index: index, + children: children, + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_list_tile/t_list_tile.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_list_tile/t_list_tile.dart new file mode 100644 index 0000000000..a9d4d3635b --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_list_tile/t_list_tile.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/index.dart'; + +const defaultListTile = 64.0; +const _animationDuration = Duration(milliseconds: 100); + +class TListTile extends StatefulWidget { + const TListTile( + {Key? key, + this.height = defaultListTile, + this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + this.focused = false, + this.backgroundColor, + this.backgroundFocusedColor, + this.backgroundHoveredColor, + this.mouseCursor = SystemMouseCursors.basic, + this.onTap, + this.onSecondaryTapUp, + required this.child}) + : super(key: key); + + final bool focused; + final double? height; + final EdgeInsets padding; + final Color? backgroundColor; + final Color? backgroundFocusedColor; + final Color? backgroundHoveredColor; + final MouseCursor mouseCursor; + final GestureTapCallback? onTap; + final GestureTapUpCallback? onSecondaryTapUp; + final Widget child; + + @override + _TListTileState createState() => _TListTileState(); +} + +class _TListTileState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final appThemeExtension = context.appThemeExtension; + return MouseRegion( + cursor: widget.mouseCursor, + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: GestureDetector( + onTap: widget.onTap, + onSecondaryTapUp: widget.onSecondaryTapUp, + child: AnimatedContainer( + height: widget.height, + alignment: Alignment.center, + color: widget.focused + ? (widget.backgroundFocusedColor ?? + appThemeExtension.tileBackgroundFocusedColor) + : (_isHovered + ? (widget.backgroundHoveredColor ?? + appThemeExtension.tileBackgroundHoveredColor) + : (widget.backgroundColor ?? + appThemeExtension.tileBackgroundColor)), + padding: widget.padding, + duration: _animationDuration, + child: widget.child))); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_loading_indicator/t_loading_indicator.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_loading_indicator/t_loading_indicator.dart new file mode 100644 index 0000000000..52e24413b9 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_loading_indicator/t_loading_indicator.dart @@ -0,0 +1,24 @@ +import 'package:flutter/cupertino.dart'; + +import '../../../themes/app_theme_extension.dart'; + +class TLoadingIndicator extends StatelessWidget { + const TLoadingIndicator({super.key, required this.text}); + + final String text; + + @override + Widget build(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, + children: [ + const RepaintBoundary( + child: CupertinoActivityIndicator(), + ), + Text( + text, + style: context.appThemeExtension.descriptionTextStyle, + ), + ], + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_menu/t_context_menu.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_menu/t_context_menu.dart new file mode 100644 index 0000000000..87e3b619f4 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_menu/t_context_menu.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/sizes.dart'; +import '../t_selection/t_selectable_region.dart'; +import 't_menu.dart'; + +const _allowedContextButtonTypes = [ + ContextMenuButtonType.cut, + ContextMenuButtonType.copy, + ContextMenuButtonType.paste, + ContextMenuButtonType.selectAll, + ContextMenuButtonType.delete, + ContextMenuButtonType.custom, +]; + +// TODO: 1. Fix the menu may not dismiss +// when the mouser interacts with other widgets. +// 2. Use custom overlay entry to control its animation. +Widget buildContextMenu({ + required BuildContext context, + required List items, + required TextSelectionToolbarAnchors anchors, +}) => + CustomSingleChildLayout( + delegate: DesktopTextSelectionToolbarLayoutDelegate( + anchor: anchors.primaryAnchor), + child: _buildContextMenu(context, items)); + +Widget buildContextMenuForSelectableRegion( + BuildContext context, SelectableRegionState selectableRegionState) => + CustomSingleChildLayout( + delegate: DesktopTextSelectionToolbarLayoutDelegate( + anchor: selectableRegionState.contextMenuAnchors.primaryAnchor), + child: _buildContextMenu( + context, selectableRegionState.contextMenuButtonItems)); + +Widget buildContextMenuForTSelectableRegion( + BuildContext context, TSelectableRegionState selectableRegionState) => + CustomSingleChildLayout( + delegate: DesktopTextSelectionToolbarLayoutDelegate( + anchor: selectableRegionState.contextMenuAnchors.primaryAnchor), + child: _buildContextMenu( + context, selectableRegionState.contextMenuButtonItems)); + +Widget _buildContextMenu( + BuildContext context, List items) { + final menuEntries = >[]; + for (final item in items) { + final onPressed = item.onPressed; + if (onPressed != null && _allowedContextButtonTypes.contains(item.type)) { + final label = item.label ?? + AdaptiveTextSelectionToolbar.getButtonLabel(context, item); + menuEntries + .add(TMenuEntry(label: label, value: label, onSelected: onPressed)); + } + } + return TMenu( + dense: true, + entries: menuEntries, + padding: Sizes.paddingV4H8, + textAlign: TextAlign.center, + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_menu/t_menu.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_menu/t_menu.dart new file mode 100644 index 0000000000..8fc3983b3d --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_menu/t_menu.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../themes/index.dart'; +import '../index.dart'; + +class TMenu extends StatefulWidget { + const TMenu({ + super.key, + this.controller, + this.value, + required this.entries, + this.onSelected, + this.dense = false, + this.focusNode, + this.textAlign = TextAlign.start, + this.padding = Sizes.paddingV8H8, + }); + + final TMenuController? controller; + final T? value; + final List> entries; + final void Function(TMenuEntry item)? onSelected; + final bool dense; + final FocusNode? focusNode; + final TextAlign textAlign; + final EdgeInsets padding; + + @override + State> createState() => _TMenuState(); +} + +class _TMenuState extends State> { + int? _hoveredEntryIndex; + + @override + void initState() { + super.initState(); + widget.controller?.move = _move; + widget.controller?.selectCurrentEntry = _selectCurrentEntry; + } + + @override + void dispose() { + widget.controller?.move = null; + widget.controller?.selectCurrentEntry = null; + super.dispose(); + } + + @override + void didUpdateWidget(TMenu oldWidget) { + super.didUpdateWidget(oldWidget); + widget.controller?.move = _move; + widget.controller?.selectCurrentEntry = _selectCurrentEntry; + } + + @override + Widget build(BuildContext context) { + final appThemeExtension = context.appThemeExtension; + final menuDecoration = appThemeExtension.menuDecoration; + if (widget.dense) { + return LayoutBuilder(builder: (context, constraints) { + final minWidth = + constraints.minWidth - menuDecoration.padding.horizontal; + if (minWidth > 0) { + return _buildContent( + context, appThemeExtension, menuDecoration, minWidth); + } + return _buildContent(context, appThemeExtension, menuDecoration, null); + }); + } + return _buildContent(context, appThemeExtension, menuDecoration, null); + } + + Widget _buildContent( + BuildContext context, + AppThemeExtension appThemeExtension, + BoxDecoration menuDecoration, + double? minWidth) { + final entries = widget.entries; + assert(entries.isNotEmpty, 'menu entries must not be empty'); + assert( + entries.length != 1 || !identical(entries.first, TMenuEntry.separator), + 'menu entries must not contain only separator'); + Widget content = DecoratedBox( + decoration: menuDecoration, + child: Padding( + padding: menuDecoration.padding, + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (final (index, entry) in entries.indexed) + _buildItem(context, appThemeExtension, index, entry, minWidth) + ], + ), + ), + ), + ); + if (widget.focusNode case final focusNode?) { + content = Focus( + focusNode: focusNode, + onKeyEvent: (node, event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + switch (event.logicalKey) { + case LogicalKeyboardKey.arrowDown: + _move(true); + return KeyEventResult.handled; + case LogicalKeyboardKey.arrowUp: + _move(false); + return KeyEventResult.handled; + case LogicalKeyboardKey.enter: + if (_hoveredEntryIndex case final index?) { + _select(entries[index]); + } + return KeyEventResult.handled; + default: + return KeyEventResult.ignored; + } + }, + child: content, + ); + } + return content; + } + + Widget _buildItem(BuildContext context, AppThemeExtension appThemeExtension, + int index, TMenuEntry entry, double? minWidth) { + if (identical(entry, TMenuEntry.separator)) { + return const THorizontalDivider(); + } + Widget content = Text( + entry.label, + textAlign: widget.textAlign, + style: appThemeExtension.menuItemTextStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + if (entry.prefix case final prefix?) { + content = Row(spacing: 8, children: [prefix, content]); + } + content = Padding( + padding: widget.padding, + child: content, + ); + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (event) => setState(() { + _hoveredEntryIndex = index; + }), + onExit: (event) => setState(() { + if (_hoveredEntryIndex == index) { + _hoveredEntryIndex = null; + } + }), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + _select(entry); + }, + child: ColoredBox( + color: _hoveredEntryIndex == index + ? appThemeExtension.menuItemHoveredColor + : appThemeExtension.menuItemColor, + child: widget.dense + ? minWidth == null + ? content + : SizedBox( + width: minWidth, + child: content, + ) + : SizedBox( + width: double.infinity, + child: content, + ), + ), + ), + ); + } + + void _move(bool down) { + var currentIndex = _hoveredEntryIndex; + final entries = widget.entries; + final count = entries.length; + while (true) { + if (down) { + currentIndex = currentIndex == null + ? 0 + : currentIndex + 1 < count + ? currentIndex + 1 + : 0; + } else { + currentIndex = currentIndex == null + ? count - 1 + : currentIndex - 1 >= 0 + ? currentIndex - 1 + : count - 1; + } + if (!identical(entries[currentIndex], TMenuEntry.separator)) { + break; + } + } + if (_hoveredEntryIndex == currentIndex) { + return; + } + _hoveredEntryIndex = currentIndex; + setState(() {}); + } + + void _select(TMenuEntry entry) { + final onSelected = entry.onSelected; + if (onSelected != null) { + onSelected(); + } + widget.onSelected?.call(entry); + } + + void _selectCurrentEntry() { + if (_hoveredEntryIndex case final index?) { + _select(widget.entries[index]); + } + } +} + +class TMenuEntry { + const TMenuEntry({ + required this.label, + this.value, + this.prefix, + this.onSelected, + }); + + static TMenuEntry separator = const TMenuEntry(label: ''); + + final String label; + final T? value; + final Widget? prefix; + final VoidCallback? onSelected; +} + +class TMenuController { + void Function(bool down)? move; + void Function()? selectCurrentEntry; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_menu/t_menu_popup.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_menu/t_menu_popup.dart new file mode 100644 index 0000000000..217d7e18c4 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_menu/t_menu_popup.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../themes/index.dart'; +import '../t_popup/t_popup.dart'; +import '../t_text_field/t_text_field.dart'; +import 't_menu.dart'; + +class TMenuPopup extends StatefulWidget { + TMenuPopup( + {super.key, + this.value, + required this.entries, + this.constrainFollowerWithTargetWidth = true, + this.targetAnchor = Alignment.bottomCenter, + this.followerAnchor = Alignment.topCenter, + this.offset = Offset.zero, + this.padding = Sizes.paddingV8H8, + this.onSelected, + required this.anchor}); + + final T? value; + final List> entries; + final bool constrainFollowerWithTargetWidth; + final Alignment targetAnchor; + final Alignment followerAnchor; + final Offset offset; + final EdgeInsets padding; + final void Function(TMenuEntry item)? onSelected; + final Widget anchor; + + @override + State> createState() => _TMenuPopupState(); +} + +class _TMenuPopupState extends State> { + late TPopupController _popupController; + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _popupController = TPopupController(); + _focusNode = FocusNode(); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => TPopup( + controller: _popupController, + constrainFollowerWithTargetWidth: widget.constrainFollowerWithTargetWidth, + targetAnchor: widget.targetAnchor, + followerAnchor: widget.followerAnchor, + offset: widget.offset, + target: widget.anchor, + onShow: () { + _focusNode.requestFocus(); + }, + followerBorderRadius: + context.appThemeExtension.popupDecoration.borderRadius!, + follower: TMenu( + focusNode: _focusNode, + value: widget.value, + entries: widget.entries, + padding: widget.padding, + onSelected: (item) { + _popupController.hidePopover?.call(); + widget.onSelected?.call(item); + }, + )); +} + +class TTextFieldMenuPopup extends StatefulWidget { + const TTextFieldMenuPopup( + {super.key, this.value, required this.entries, required this.onSelected}); + + final T? value; + final List> entries; + final void Function(TMenuEntry item) onSelected; + + @override + State> createState() => _TTextFieldMenuPopupState(); +} + +class _TTextFieldMenuPopupState extends State> { + late TextEditingController _textEditingController; + + @override + void initState() { + super.initState(); + _textEditingController = TextEditingController(); + _updateEditorToValue(); + } + + @override + void didUpdateWidget(TTextFieldMenuPopup oldWidget) { + super.didUpdateWidget(oldWidget); + _updateEditorToValue(); + } + + @override + void dispose() { + _textEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => TMenuPopup( + value: widget.value, + entries: widget.entries, + onSelected: widget.onSelected, + anchor: IgnorePointer( + child: TTextField( + textEditingController: _textEditingController, + readOnly: true, + showCursor: false, + mouseCursor: SystemMouseCursors.basic, + suffixIcon: const Icon( + Symbols.arrow_drop_down_rounded, + ), + ), + )); + + void _updateEditorToValue() { + if (widget.value case final value?) { + _textEditingController.text = + widget.entries.firstWhere((entry) => entry.value == value).label; + } + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_popup/t_popup.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_popup/t_popup.dart new file mode 100644 index 0000000000..574f0bdce2 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_popup/t_popup.dart @@ -0,0 +1,400 @@ +import 'package:flutter/material.dart'; + +import '../../../../infra/random/random_utils.dart'; +import 't_popup_follower.dart'; + +class TPopup extends StatefulWidget { + const TPopup( + {super.key, + required this.target, + required this.follower, + this.targetAnchor = Alignment.topLeft, + this.followerAnchor = Alignment.topLeft, + this.followerBorderRadius, + this.offset = Offset.zero, + this.followTargetMove = false, + this.controller, + this.constrainFollowerWithTargetWidth = false, + this.onShow, + this.onDismissed}); + + final Widget target; + final Widget follower; + + final Alignment followerAnchor; + final BorderRadiusGeometry? followerBorderRadius; + final Alignment targetAnchor; + final Offset offset; + + final bool followTargetMove; + + final bool constrainFollowerWithTargetWidth; + + final TPopupController? controller; + final VoidCallback? onShow; + final VoidCallback? onDismissed; + + @override + State createState() => _TPopupState(); +} + +class _TPopupState extends State { + final GlobalKey _targetKey = GlobalKey(); + final LayerLink _layerLink = LayerLink(); + OverlayEntry? _overlayEntry; + bool _visible = false; + final TPopupFollowerController _followerController = + TPopupFollowerController(); + + @override + void initState() { + super.initState(); + final controller = widget.controller; + if (controller != null) { + controller + ..showPopover = _showPopup + ..hidePopover = _hidePopup; + } + } + + @override + void dispose() { + final controller = widget.controller; + if (controller != null) { + controller + ..showPopover = null + ..hidePopover = null; + } + _removeOverlayEntry(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + key: _targetKey, + onTap: _togglePopup, + child: CompositedTransformTarget( + link: _layerLink, + child: widget.target, + ), + )); + + @override + void didUpdateWidget(TPopup oldWidget) { + super.didUpdateWidget(oldWidget); + final controller = widget.controller; + // We allow the old controller to control the new widget, + // so no need to unregister it. + if (controller != null) { + controller + ..showPopover = _showPopup + ..hidePopover = _hidePopup; + } + } + + void _togglePopup() { + if (_visible) { + _hidePopup(); + } else { + _showPopup(); + } + } + + void _showPopup() { + if (_visible) { + return; + } + if (_overlayEntry == null) { + _overlayEntry = _createOverlayEntry(); + Overlay.of(context).insert(_overlayEntry!); + } else { + _followerController.show?.call(); + } + widget.onShow?.call(); + _visible = true; + } + + void _hidePopup() { + if (!_visible) { + return; + } + _followerController.hide?.call(); + _visible = false; + } + + void _removeOverlayEntry() { + if (_overlayEntry case final overlayEntry?) { + overlayEntry.remove(); + _overlayEntry = null; + } + _visible = false; + } + + OverlayEntry _createOverlayEntry() => OverlayEntry( + builder: (BuildContext context) => widget.followTargetMove + ? _buildTrackingFollower() + : _buildFixedFollower()); + + Widget _buildTrackingFollower() => CompositedTransformFollower( + link: _layerLink, + followerAnchor: widget.followerAnchor, + targetAnchor: widget.targetAnchor, + offset: widget.offset, + child: _buildFollower( + controller: _followerController, + width: widget.constrainFollowerWithTargetWidth + ? _layerLink.leaderSize?.width + : null, + animate: true, + onTapOutside: (event) => _hidePopup(), + onDismissed: () { + _removeOverlayEntry(); + widget.onDismissed?.call(); + }, + child: widget.follower)); + + Widget _buildFixedFollower() => TPopupFixedFollower( + targetGlobalRect: _getTargetGlobalRect(), + controller: _followerController, + child: widget.follower, + targetAnchor: widget.targetAnchor, + followerAnchor: widget.followerAnchor, + followerWidth: widget.constrainFollowerWithTargetWidth + ? _layerLink.leaderSize?.width + : null, + followerBorderRadius: widget.followerBorderRadius, + offset: widget.offset, + onTapOutside: (event) => _hidePopup(), + onDismissed: () { + _removeOverlayEntry(); + widget.onDismissed?.call(); + }); + + Rect _getTargetGlobalRect() { + final renderBox = + _targetKey.currentContext!.findRenderObject()! as RenderBox; + final topLeftOffset = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + return Rect.fromLTWH( + topLeftOffset.dx, topLeftOffset.dy, size.width, size.height); + } +} + +class TPopupFixedFollower extends StatelessWidget { + const TPopupFixedFollower({ + super.key, + required this.controller, + required this.targetGlobalRect, + required this.targetAnchor, + required this.followerAnchor, + this.followerWidth, + this.followerBorderRadius, + required this.offset, + this.animate = true, + required this.onTapOutside, + required this.onDismissed, + required this.child, + }); + + final TPopupFollowerController controller; + final Rect targetGlobalRect; + final Alignment targetAnchor; + final Alignment followerAnchor; + final double? followerWidth; + final BorderRadiusGeometry? followerBorderRadius; + final Offset offset; + final bool animate; + final TapRegionCallback onTapOutside; + final VoidCallback onDismissed; + final Widget child; + + @override + Widget build(BuildContext context) => Positioned.fill( + child: CustomSingleChildLayout( + delegate: TPopupLayout( + targetRect: targetGlobalRect, + targetAnchor: targetAnchor, + followerAnchor: followerAnchor, + offset: offset, + ), + child: Material( + color: Colors.transparent, + borderRadius: followerBorderRadius, + child: _buildFollower( + controller: controller, + width: followerWidth, + animate: animate, + onTapOutside: onTapOutside, + onDismissed: onDismissed, + child: child, + ), + ), + )); +} + +TapRegion _buildFollower({ + required TPopupFollowerController controller, + required double? width, + required bool animate, + required TapRegionCallback onTapOutside, + required VoidCallback onDismissed, + required Widget child, +}) => + TapRegion( + onTapOutside: onTapOutside, + child: TPopupFollower( + controller: controller, + animate: animate, + onDismissed: onDismissed, + child: width != null + ? SizedBox( + width: width, + child: child, + ) + : child, + ), + ); + +class TPopupController { + void Function()? showPopover; + void Function()? hidePopover; +} + +class _TPopupInfo { + const _TPopupInfo({required this.overlayEntry, required this.hideOrRemove}); + + final OverlayEntry overlayEntry; + final void Function() hideOrRemove; +} + +final _idToPopupInfo = {}; + +void showPopup({ + BuildContext? context, + OverlayState? overlay, + String? id, + TPopupFollowerController? controller, + bool hideOtherPopups = true, + bool animate = true, + required Rect targetGlobalRect, + required Alignment targetAnchor, + required Alignment followerAnchor, + Offset offset = Offset.zero, + VoidCallback? onDismissed, + required Widget follower, +}) { + overlay ??= Overlay.of(context!, rootOverlay: true); + id ??= RandomUtils.nextUniquePositiveInt64().toString(); + final followerController = controller ?? TPopupFollowerController(); + void hidePopup() { + followerController.hide?.call(); + } + + void removePopup() { + final removedPopup = _idToPopupInfo.remove(id); + removedPopup?.overlayEntry.remove(); + onDismissed?.call(); + } + + final hideOrRemove = animate ? hidePopup : removePopup; + + final overlayEntry = OverlayEntry( + builder: (context) => TPopupFixedFollower( + targetGlobalRect: targetGlobalRect, + controller: followerController, + targetAnchor: targetAnchor, + followerAnchor: followerAnchor, + animate: animate, + onTapOutside: (event) { + hideOrRemove(); + }, + onDismissed: removePopup, + offset: offset, + child: follower, + ), + ); + if (hideOtherPopups) { + hideAllPopups(); + } + _idToPopupInfo[id] = + _TPopupInfo(overlayEntry: overlayEntry, hideOrRemove: hideOrRemove); + overlay.insert(overlayEntry); +} + +void hideAllPopups() { + // We need to make a copy of the list + // because hideOrRemove() may remove the popup from the list + // while iterating over it. + final allPopups = _idToPopupInfo.values.toList(); + for (final popup in allPopups) { + popup.hideOrRemove(); + } +} + +class TPopupLayout extends SingleChildLayoutDelegate { + const TPopupLayout( + {required this.targetRect, + required this.targetAnchor, + required this.followerAnchor, + required this.offset}); + + final Rect targetRect; + final Alignment targetAnchor; + final Alignment followerAnchor; + final Offset offset; + + @override + bool shouldRelayout(TPopupLayout oldDelegate) => + targetRect != oldDelegate.targetRect || + targetAnchor != oldDelegate.targetAnchor || + followerAnchor != oldDelegate.followerAnchor || + offset != oldDelegate.offset; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) => + constraints.loosen(); + + @override + Offset getPositionForChild(Size size, Size childSize) => + getPosition(size, childSize); + + Offset getPosition(Size containerSize, Size childSize) { + final preferredTargetTopLeft = targetRect.topLeft + + targetAnchor.alongSize(targetRect.size) - + followerAnchor.alongSize(childSize) + + offset; + if (preferredTargetTopLeft.dx < 0 || + preferredTargetTopLeft.dx + childSize.width > containerSize.width) { + if (preferredTargetTopLeft.dy < 0 || + preferredTargetTopLeft.dy + childSize.height > containerSize.height) { + return targetRect.topLeft + + targetAnchor.alongSize(targetRect.size) - + followerAnchor.flipped().alongSize(childSize) - + offset; + } else { + return targetRect.topLeft + + targetAnchor.alongSize(targetRect.size) - + followerAnchor.flippedX().alongSize(childSize) + + Offset(-offset.dx, offset.dy); + } + } else if (preferredTargetTopLeft.dy < 0 || + preferredTargetTopLeft.dy + childSize.height > containerSize.height) { + return targetRect.topLeft + + targetAnchor.alongSize(targetRect.size) - + followerAnchor.flippedY().alongSize(childSize) + + Offset(offset.dx, -offset.dy); + } + return preferredTargetTopLeft; + } +} + +extension on Alignment { + Alignment flipped() => Alignment(-x, -y); + + Alignment flippedX() => Alignment(-x, y); + + Alignment flippedY() => Alignment(x, -y); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_popup/t_popup_follower.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_popup/t_popup_follower.dart new file mode 100644 index 0000000000..64e08a4558 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_popup/t_popup_follower.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../infra/animation/animation_extensions.dart'; +import '../../../../infra/io/global_keyboard_listener.dart'; + +class TPopupFollowerController { + bool visible = false; + + void Function()? show; + void Function()? hide; +} + +class TPopupFollower extends StatefulWidget { + const TPopupFollower({ + Key? key, + this.animate = true, + this.controller, + required this.onDismissed, + required this.child, + }) : super(key: key); + + final bool animate; + final TPopupFollowerController? controller; + final void Function() onDismissed; + final Widget child; + + @override + State createState() => _TPopupFollowerState(); +} + +class _TPopupFollowerState extends State + with SingleTickerProviderStateMixin { + AnimationController? _animationController; + + Animation? animation; + AnimationStatus _animationStatus = AnimationStatus.dismissed; + + @override + void initState() { + super.initState(); + + if (widget.animate) { + final animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 150), + reverseDuration: const Duration(milliseconds: 150), + )..addStatusListener(_handleStatusChanged); + _animationController = animationController; + animation = CurvedAnimation( + parent: animationController, curve: Curves.fastOutSlowIn); + } + + final controller = widget.controller; + if (controller != null) { + controller + ..show = _show + ..hide = _hide; + } + + _show(); + } + + void _handleStatusChanged(AnimationStatus status) { + assert(mounted); + final isVisible = _isTooltipVisible(status); + switch ((_isTooltipVisible(_animationStatus), isVisible)) { + case (true, false): + widget.onDismissed(); + case (false, true): + // widget.onDisplay(); + case (true, true) || (false, false): + break; + } + _animationStatus = status; + widget.controller?.visible = isVisible; + } + + static bool _isTooltipVisible(AnimationStatus status) => + status.isNotDismissed; + + @override + Widget build(BuildContext context) { + final _animation = animation; + final child = SingleChildScrollView( + child: widget.child, + ); + return GlobalKeyboardListener( + onKeyEvent: (KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + final controller = _animationController; + if (controller == null || controller.isCompleted) { + _hide(); + return true; + } + } + return false; + }, + child: _animation == null + ? child + : FadeTransition( + opacity: _animation, + child: child, + ), + ); + } + + @override + void dispose() { + _animationController?.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(TPopupFollower oldWidget) { + super.didUpdateWidget(oldWidget); + // TODO: handle the change of animate + final oldController = oldWidget.controller; + final currentController = widget.controller; + if (currentController == oldController) { + return; + } + if (oldController != null) { + oldController + ..show = null + ..hide = null; + } + if (currentController != null) { + currentController + ..show = _show + ..hide = _hide; + } + } + + Future _show() async { + if (mounted) { + if (widget.animate) { + await _animationController!.forward(); + } else { + widget.controller?.visible = true; + } + } + } + + Future _hide() async { + if (!mounted) { + return; + } + + if (widget.animate) { + await _animationController!.reverse(); + } else { + widget.controller?.visible = false; + widget.onDismissed(); + } + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_radio/t_radio.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_radio/t_radio.dart new file mode 100644 index 0000000000..4d8dc0c9ed --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_radio/t_radio.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/index.dart'; + +class TRadio extends StatefulWidget { + const TRadio({ + Key? key, + required this.value, + required this.groupValue, + required this.onChanged, + this.size = 16, + this.radioColor = const Color(0xff10DC60), + this.activeBgColor = Colors.white, + this.inactiveBgColor = Colors.white, + this.activeBorderColor, + this.inactiveBorderColor, + this.toggleable = false, + this.label, + }) : super(key: key); + + final double size; + + final Color radioColor; + + final Color activeBgColor; + + final Color inactiveBgColor; + + final Color? activeBorderColor; + + final Color? inactiveBorderColor; + + final ValueChanged onChanged; + + final T value; + + final T groupValue; + + final bool toggleable; + final String? label; + + @override + _TRadioState createState() => _TRadioState(); +} + +class _TRadioState extends State> with TickerProviderStateMixin { + bool _selected = false; + T? _groupValue; + + void onStatusChange() { + _groupValue = widget.value; + _handleChanged(widget.value == _groupValue); + } + + void _handleChanged(bool selected) { + if (selected) { + widget.onChanged(widget.value); + } + } + + @override + Widget build(BuildContext context) { + _selected = widget.value == widget.groupValue; + final label = widget.label; + final size = widget.size; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onStatusChange, + child: Row(spacing: 8, mainAxisSize: MainAxisSize.min, children: [ + SizedBox( + height: size, + width: size, + child: DecoratedBox( + decoration: BoxDecoration( + color: + _selected ? widget.activeBgColor : widget.inactiveBgColor, + shape: BoxShape.circle, + border: Border.all( + color: _selected + ? (widget.activeBorderColor ?? + context.theme.dividerColor) + : (widget.inactiveBorderColor ?? + context.theme.dividerColor))), + child: _selected + ? Center( + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, color: widget.radioColor), + child: SizedBox(width: size * 0.5, height: size * 0.5), + ), + ) + : null, + ), + ), + if (label != null) Text(label) + ]), + ), + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_screenshot/t_screenshot.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_screenshot/t_screenshot.dart new file mode 100644 index 0000000000..924c7442b9 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_screenshot/t_screenshot.dart @@ -0,0 +1,41 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:image/image.dart' as img; + +class TScreenshot extends StatelessWidget { + const TScreenshot({ + Key? key, + required this.child, + required this.controller, + }) : super(key: key); + + final Widget child; + final TScreenshotController controller; + + @override + Widget build(BuildContext context) => RepaintBoundary( + key: controller._containerKey, + child: child, + ); +} + +class TScreenshotController { + final _containerKey = GlobalKey(); + + Future capture({int quality = 80}) async => compute((_) async { + final boundary = _containerKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) { + return null; + } + final image = await boundary.toImage(); + final byteData = await image.toByteData(); + if (byteData == null) { + return null; + } + final outputImage = img.Image.fromBytes( + width: image.width, height: image.height, bytes: byteData.buffer); + return img.encodeJpg(outputImage, quality: quality); + }, null); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_search_bar/t_search_bar.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_search_bar/t_search_bar.dart new file mode 100644 index 0000000000..b51dbd81bd --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_search_bar/t_search_bar.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../t_text_field/t_text_field.dart'; + +class TSearchBar extends StatelessWidget { + const TSearchBar( + {super.key, + this.style, + required this.hintText, + this.textEditingController, + this.autofocus = false, + this.keepFocusOnSubmit = false, + this.focusNode, + this.prefixIcon = const Icon(Symbols.search_rounded, size: 16), + this.debounceTimeout, + this.transformValue, + this.onChanged, + this.onSubmitted}); + + final TextEditingController? textEditingController; + final TextStyle? style; + final String hintText; + final Widget prefixIcon; + final bool autofocus; + final bool keepFocusOnSubmit; + final FocusNode? focusNode; + final Duration? debounceTimeout; + final String Function(String)? transformValue; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + + @override + Widget build(BuildContext context) => TTextField( + textEditingController: textEditingController, + autofocus: autofocus, + keepFocusOnSubmit: keepFocusOnSubmit, + focusNode: focusNode, + style: style, + hintText: hintText, + prefixIcon: prefixIcon, + prefixIconConstraints: const BoxConstraints.tightFor(width: 24), + showDeleteButtonIfHasText: true, + debounceTimeout: debounceTimeout, + transformValue: transformValue, + onChanged: onChanged, + onSubmitted: onSubmitted, + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_selection/t_selectable_region.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_selection/t_selectable_region.dart new file mode 100644 index 0000000000..672345298a --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_selection/t_selectable_region.dart @@ -0,0 +1,2126 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; + +// Examples can assume: +// FocusNode _focusNode = FocusNode(); +// late GlobalKey key; + +const Set _kLongPressSelectionDevices = { + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, +}; + +// In practice some selectables like widgetspan shift several pixels. So when +// the vertical position diff is within the threshold, compare the horizontal +// position to make the compareScreenOrder function more robust. +const double _kSelectableVerticalComparingThreshold = 3.0; + +/// A widget that introduces an area for user selections. +/// +/// Flutter widgets are not selectable by default. Wrapping a widget subtree +/// with a [TSelectableRegion] widget enables selection within that subtree (for +/// example, [Text] widgets automatically look for selectable regions to enable +/// selection). The wrapped subtree can be selected by users using mouse or +/// touch gestures, e.g. users can select widgets by holding the mouse +/// left-click and dragging across widgets, or they can use long press gestures +/// to select words on touch devices. +/// +/// A [TSelectableRegion] widget requires configuration; in particular specific +/// [selectionControls] must be provided. +/// +/// The [SelectionArea] widget from the [material] library configures a +/// [TSelectableRegion] in a platform-specific manner (e.g. using a Material +/// toolbar on Android, a Cupertino toolbar on iOS), and it may therefore be +/// simpler to use that widget rather than using [TSelectableRegion] directly. +/// +/// ## An overview of the selection system. +/// +/// Every [Selectable] under the [TSelectableRegion] can be selected. They form a +/// selection tree structure to handle the selection. +/// +/// The [TSelectableRegion] is a wrapper over [SelectionContainer]. It listens to +/// user gestures and sends corresponding [SelectionEvent]s to the +/// [SelectionContainer] it creates. +/// +/// A [SelectionContainer] is a single [Selectable] that handles +/// [SelectionEvent]s on behalf of child [Selectable]s in the subtree. It +/// creates a [SelectionRegistrarScope] with its [SelectionContainer.delegate] +/// to collect child [Selectable]s and sends the [SelectionEvent]s it receives +/// from the parent [SelectionRegistrar] to the appropriate child [Selectable]s. +/// It creates an abstraction for the parent [SelectionRegistrar] as if it is +/// interacting with a single [Selectable]. +/// +/// The [SelectionContainer] created by [TSelectableRegion] is the root node of a +/// selection tree. Each non-leaf node in the tree is a [SelectionContainer], +/// and the leaf node is a leaf widget whose render object implements +/// [Selectable]. They are connected through [SelectionRegistrarScope]s created +/// by [SelectionContainer]s. +/// +/// Both [SelectionContainer]s and the leaf [Selectable]s need to register +/// themselves to the [SelectionRegistrar] from the +/// [SelectionContainer.maybeOf] if they want to participate in the +/// selection. +/// +/// An example selection tree will look like: +/// +/// {@tool snippet} +/// +/// ```dart +/// MaterialApp( +/// home: SelectableRegion( +/// selectionControls: materialTextSelectionControls, +/// focusNode: _focusNode, // initialized to FocusNode() +/// child: Scaffold( +/// appBar: AppBar(title: const Text('Flutter Code Sample')), +/// body: ListView( +/// children: const [ +/// Text('Item 0', style: TextStyle(fontSize: 50.0)), +/// Text('Item 1', style: TextStyle(fontSize: 50.0)), +/// ], +/// ), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// +/// SelectionContainer +/// (SelectableRegion) +/// / \ +/// / \ +/// / \ +/// Selectable \ +/// ("Flutter Code Sample") \ +/// \ +/// SelectionContainer +/// (ListView) +/// / \ +/// / \ +/// / \ +/// Selectable Selectable +/// ("Item 0") ("Item 1") +/// +/// +/// ## Making a widget selectable +/// +/// Some leaf widgets, such as [Text], have all of the selection logic wired up +/// automatically and can be selected as long as they are under a +/// [TSelectableRegion]. +/// +/// To make a custom selectable widget, its render object needs to mix in +/// [Selectable] and implement the required APIs to handle [SelectionEvent]s +/// as well as paint appropriate selection highlights. +/// +/// The render object also needs to register itself to a [SelectionRegistrar]. +/// For the most cases, one can use [SelectionRegistrant] to auto-register +/// itself with the register returned from [SelectionContainer.maybeOf] as +/// seen in the example below. +/// +/// {@tool dartpad} +/// This sample demonstrates how to create an adapter widget that makes any +/// child widget selectable. +/// +/// ** See code in examples/api/lib/material/selectable_region/selectable_region.0.dart ** +/// {@end-tool} +/// +/// ## Complex layout +/// +/// By default, the screen order is used as the selection order. If a group of +/// [Selectable]s needs to select differently, consider wrapping them with a +/// [SelectionContainer] to customize its selection behavior. +/// +/// {@tool dartpad} +/// This sample demonstrates how to create a [SelectionContainer] that only +/// allows selecting everything or nothing with no partial selection. +/// +/// ** See code in examples/api/lib/material/selection_container/selection_container.0.dart ** +/// {@end-tool} +/// +/// In the case where a group of widgets should be excluded from selection under +/// a [TSelectableRegion], consider wrapping that group of widgets using +/// [SelectionContainer.disabled]. +/// +/// {@tool dartpad} +/// This sample demonstrates how to disable selection for a Text in a Column. +/// +/// ** See code in examples/api/lib/material/selection_container/selection_container_disabled.0.dart ** +/// {@end-tool} +/// +/// To create a separate selection system from its parent selection area, +/// wrap part of the subtree with another [TSelectableRegion]. The selection of the +/// child selection area can not extend past its subtree, and the selection of +/// the parent selection area can not extend inside the child selection area. +/// +/// ## Tests +/// +/// In a test, a region can be selected either by faking drag events (e.g. using +/// [WidgetTester.dragFrom]) or by sending intents to a widget inside the region +/// that has been given a [GlobalKey], e.g.: +/// +/// ```dart +/// Actions.invoke(key.currentContext!, const SelectAllTextIntent(SelectionChangedCause.keyboard)); +/// ``` +/// +/// See also: +/// +/// * [SelectionArea], which creates a [TSelectableRegion] with +/// platform-adaptive selection controls. +/// * [SelectableText], which enables selection on a single run of text. +/// * [SelectionHandler], which contains APIs to handle selection events from the +/// [TSelectableRegion]. +/// * [Selectable], which provides API to participate in the selection system. +/// * [SelectionRegistrar], which [Selectable] needs to subscribe to receive +/// selection events. +/// * [SelectionContainer], which collects selectable widgets in the subtree +/// and provides api to dispatch selection event to the collected widget. +class TSelectableRegion extends StatefulWidget { + /// Create a new [TSelectableRegion] widget. + /// + /// The [selectionControls] are used for building the selection handles and + /// toolbar for mobile devices. + const TSelectableRegion({ + super.key, + this.contextMenuBuilder, + required this.focusNode, + required this.selectionControls, + required this.child, + this.magnifierConfiguration = TextMagnifierConfiguration.disabled, + this.onSelectionChanged, + }); + + /// The configuration for the magnifier used with selections in this region. + /// + /// By default, [TSelectableRegion]'s [TextMagnifierConfiguration] is disabled. + /// For a version of [TSelectableRegion] that adapts automatically to the + /// current platform, consider [SelectionArea]. + /// + /// {@macro flutter.widgets.magnifier.intro} + final TextMagnifierConfiguration magnifierConfiguration; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode focusNode; + + /// The child widget this selection area applies to. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + final TSelectableRegionContextMenuBuilder? contextMenuBuilder; + + /// The delegate to build the selection handles and toolbar for mobile + /// devices. + /// + /// The [emptyTextSelectionControls] global variable provides a default + /// [TextSelectionControls] implementation with no controls. + final TextSelectionControls selectionControls; + + /// Called when the selected content changes. + final ValueChanged? onSelectionChanged; + + /// Returns the [ContextMenuButtonItem]s representing the buttons in this + /// platform's default selection menu. + /// + /// For example, [TSelectableRegion] uses this to generate the default buttons + /// for its context menu. + /// + /// See also: + /// + /// * [TSelectableRegionState.contextMenuButtonItems], which gives the + /// [ContextMenuButtonItem]s for a specific SelectableRegion. + /// * [EditableText.getEditableButtonItems], which performs a similar role but + /// for content that is both selectable and editable. + /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can + /// take a list of [ContextMenuButtonItem]s with + /// [AdaptiveTextSelectionToolbar.buttonItems]. + /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button + /// Widgets for the current platform given [ContextMenuButtonItem]s. + static List getSelectableButtonItems({ + required final SelectionGeometry selectionGeometry, + required final VoidCallback onCopy, + required final VoidCallback onSelectAll, + required final VoidCallback? onShare, + }) { + final canCopy = selectionGeometry.status == SelectionStatus.uncollapsed; + final canSelectAll = selectionGeometry.hasContent; + final platformCanShare = switch (defaultTargetPlatform) { + TargetPlatform.android => + selectionGeometry.status == SelectionStatus.uncollapsed, + TargetPlatform.macOS || + TargetPlatform.fuchsia || + TargetPlatform.linux || + TargetPlatform.windows => + false, + // TODO(bleroux): the share button should be shown on iOS but the share + // functionality requires some changes on the engine side because, on iPad, + // it needs an anchor for the popup. + // See: https://github.com/flutter/flutter/issues/141775. + TargetPlatform.iOS => false, + }; + final canShare = onShare != null && platformCanShare; + + // On Android, the share button is before the select all button. + final showShareBeforeSelectAll = + defaultTargetPlatform == TargetPlatform.android; + + // Determine which buttons will appear so that the order and total number is + // known. A button's position in the menu can slightly affect its + // appearance. + return [ + if (canCopy) + ContextMenuButtonItem( + onPressed: onCopy, + type: ContextMenuButtonType.copy, + ), + if (canShare && showShareBeforeSelectAll) + ContextMenuButtonItem( + onPressed: onShare, + type: ContextMenuButtonType.share, + ), + if (canSelectAll) + ContextMenuButtonItem( + onPressed: onSelectAll, + type: ContextMenuButtonType.selectAll, + ), + if (canShare && !showShareBeforeSelectAll) + ContextMenuButtonItem( + onPressed: onShare, + type: ContextMenuButtonType.share, + ), + ]; + } + + @override + State createState() => TSelectableRegionState(); +} + +/// State for a [TSelectableRegion]. +class TSelectableRegionState extends State + with TextSelectionDelegate + implements SelectionRegistrar { + late final Map> _actions = >{ + // SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), + CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable( + _GranularlyExtendSelectionAction< + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent>(this, + granularity: TextGranularity.word)), + ExpandSelectionToDocumentBoundaryIntent: _makeOverridable( + _GranularlyExtendSelectionAction< + ExpandSelectionToDocumentBoundaryIntent>(this, + granularity: TextGranularity.document)), + ExpandSelectionToLineBreakIntent: _makeOverridable( + _GranularlyExtendSelectionAction(this, + granularity: TextGranularity.line)), + ExtendSelectionByCharacterIntent: _makeOverridable( + _GranularlyExtendCaretSelectionAction( + this, + granularity: TextGranularity.character)), + ExtendSelectionToNextWordBoundaryIntent: _makeOverridable( + _GranularlyExtendCaretSelectionAction< + ExtendSelectionToNextWordBoundaryIntent>(this, + granularity: TextGranularity.word)), + ExtendSelectionToLineBreakIntent: _makeOverridable( + _GranularlyExtendCaretSelectionAction( + this, + granularity: TextGranularity.line)), + ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable( + _DirectionallyExtendCaretSelectionAction< + ExtendSelectionVerticallyToAdjacentLineIntent>(this)), + ExtendSelectionToDocumentBoundaryIntent: _makeOverridable( + _GranularlyExtendCaretSelectionAction< + ExtendSelectionToDocumentBoundaryIntent>(this, + granularity: TextGranularity.document)), + }; + + final Map _gestureRecognizers = + {}; + SelectionOverlay? _selectionOverlay; + final LayerLink _startHandleLayerLink = LayerLink(); + final LayerLink _endHandleLayerLink = LayerLink(); + final LayerLink _toolbarLayerLink = LayerLink(); + final _SelectableRegionContainerDelegate _selectionDelegate = + _SelectableRegionContainerDelegate(); + + // there should only ever be one selectable, which is the SelectionContainer. + Selectable? _selectable; + + bool get _hasSelectionOverlayGeometry => + _selectionDelegate.value.startSelectionPoint != null || + _selectionDelegate.value.endSelectionPoint != null; + + Orientation? _lastOrientation; + SelectedContent? _lastSelectedContent; + + /// The [SelectionOverlay] that is currently visible on the screen. + /// + /// Can be null if there is no visible [SelectionOverlay]. + @visibleForTesting + SelectionOverlay? get selectionOverlay => _selectionOverlay; + + /// The text processing service used to retrieve the native text processing actions. + final ProcessTextService _processTextService = DefaultProcessTextService(); + + /// The list of native text processing actions provided by the engine. + final List _processTextActions = []; + + @override + void initState() { + super.initState(); + widget.focusNode.addListener(_handleFocusChanged); + _initMouseGestureRecognizer(); + _initTouchGestureRecognizer(); + // Right clicks. + _gestureRecognizers[TapGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (TapGestureRecognizer instance) { + instance.onSecondaryTapDown = _handleRightClickDown; + }, + ); + _initProcessTextActions(); + } + + /// Query the engine to initialize the list of text processing actions to show + /// in the text selection toolbar. + Future _initProcessTextActions() async { + _processTextActions.clear(); + _processTextActions.addAll(await _processTextService.queryTextActions()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + break; + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return; + } + + // Hide the text selection toolbar on mobile when orientation changes. + final orientation = MediaQuery.orientationOf(context); + if (_lastOrientation == null) { + _lastOrientation = orientation; + return; + } + if (orientation != _lastOrientation) { + _lastOrientation = orientation; + hideToolbar(defaultTargetPlatform == TargetPlatform.android); + } + } + + @override + void didUpdateWidget(TSelectableRegion oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_handleFocusChanged); + widget.focusNode.addListener(_handleFocusChanged); + if (widget.focusNode.hasFocus != oldWidget.focusNode.hasFocus) { + _handleFocusChanged(); + } + } + } + + Action _makeOverridable(Action defaultAction) => + Action.overridable(context: context, defaultAction: defaultAction); + + void _handleFocusChanged() { + if (!widget.focusNode.hasFocus) { + if (kIsWeb) { + PlatformSelectableRegionContextMenu.detach(_selectionDelegate); + } + if (SchedulerBinding.instance.lifecycleState == + AppLifecycleState.resumed) { + // We should only clear the selection when this SelectableRegion loses + // focus while the application is currently running. It is possible + // that the application is not currently running, for example on desktop + // platforms, clicking on a different window switches the focus to + // the new window causing the Flutter application to go inactive. In this + // case we want to retain the selection so it remains when we return to + // the Flutter application. + clearSelection(); + } + } + if (kIsWeb) { + PlatformSelectableRegionContextMenu.attach(_selectionDelegate); + } + } + + void _updateSelectionStatus() { + final geometry = _selectionDelegate.value; + final selection = switch (geometry.status) { + SelectionStatus.uncollapsed || + SelectionStatus.collapsed => + const TextSelection(baseOffset: 0, extentOffset: 1), + SelectionStatus.none => const TextSelection.collapsed(offset: 1), + }; + textEditingValue = TextEditingValue(text: '__', selection: selection); + if (_hasSelectionOverlayGeometry) { + _updateSelectionOverlay(); + } else { + _selectionOverlay?.dispose(); + _selectionOverlay = null; + } + } + + // gestures. + + /// Whether the Shift key was pressed when the most recent [PointerDownEvent] + /// was tracked by the [BaseTapAndDragGestureRecognizer]. + bool _isShiftPressed = false; + + // The position of the most recent secondary tap down event on this + // SelectableRegion. + Offset? _lastSecondaryTapDownPosition; + + // The device kind for the pointer of the most recent tap down event on this + // SelectableRegion. + PointerDeviceKind? _lastPointerDeviceKind; + + static bool _isPrecisePointerDevice(PointerDeviceKind pointerDeviceKind) { + switch (pointerDeviceKind) { + case PointerDeviceKind.mouse: + return true; + case PointerDeviceKind.trackpad: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + return false; + } + } + + // Converts the details.consecutiveTapCount from a TapAndDrag*Details object, + // which can grow to be infinitely large, to a value between 1 and the supported + // max consecutive tap count. The value that the raw count is converted to is + // based on the default observed behavior on the native platforms. + // + // This method should be used in all instances when details.consecutiveTapCount + // would be used. + int _getEffectiveConsecutiveTapCount(int rawCount) { + var maxConsecutiveTap = 3; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + if (_lastPointerDeviceKind != null && + _lastPointerDeviceKind != PointerDeviceKind.mouse) { + // When the pointer device kind is not precise like a mouse, native + // Android resets the tap count at 2. For example, this is so the + // selection can collapse on the third tap. + maxConsecutiveTap = 2; + } + // From observation, these platforms reset their tap count to 0 when + // the number of consecutive taps exceeds the max consecutive tap supported. + // For example on native Android, when going past a triple click, + // on the fourth click the selection is moved to the precise click + // position, on the fifth click the word at the position is selected, and + // on the sixth click the paragraph at the position is selected. + return rawCount <= maxConsecutiveTap + ? rawCount + : (rawCount % maxConsecutiveTap == 0 + ? maxConsecutiveTap + : rawCount % maxConsecutiveTap); + case TargetPlatform.linux: + // From observation, these platforms reset their tap count to 0 when + // the number of consecutive taps exceeds the max consecutive tap supported. + // For example on Debian Linux with GTK, when going past a triple click, + // on the fourth click the selection is moved to the precise click + // position, on the fifth click the word at the position is selected, and + // on the sixth click the paragraph at the position is selected. + return rawCount <= maxConsecutiveTap + ? rawCount + : (rawCount % maxConsecutiveTap == 0 + ? maxConsecutiveTap + : rawCount % maxConsecutiveTap); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.windows: + // From observation, these platforms hold their tap count at the max + // consecutive tap supported. For example on macOS, when going past a triple + // click, the selection should be retained at the paragraph that was first + // selected on triple click. + return min(rawCount, maxConsecutiveTap); + } + } + + void _initMouseGestureRecognizer() { + _gestureRecognizers[TapAndPanGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => TapAndPanGestureRecognizer( + debugOwner: this, + supportedDevices: {PointerDeviceKind.mouse}, + ), + (TapAndPanGestureRecognizer instance) { + instance + ..onTapTrackStart = _onTapTrackStart + ..onTapTrackReset = _onTapTrackReset + ..onTapDown = _startNewMouseSelectionGesture + ..onTapUp = _handleMouseTapUp + ..onDragStart = _handleMouseDragStart + ..onDragUpdate = _handleMouseDragUpdate + ..onDragEnd = _handleMouseDragEnd + ..onCancel = clearSelection + ..dragStartBehavior = DragStartBehavior.down; + }, + ); + } + + void _onTapTrackStart() { + _isShiftPressed = HardwareKeyboard.instance.logicalKeysPressed + .intersection({ + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight + }).isNotEmpty; + } + + void _onTapTrackReset() { + _isShiftPressed = false; + } + + void _initTouchGestureRecognizer() { + // A [TapAndHorizontalDragGestureRecognizer] is used on non-precise pointer devices + // like PointerDeviceKind.touch so [SelectableRegion] gestures do not conflict with + // ancestor Scrollable gestures in common scenarios like a vertically scrolling list view. + _gestureRecognizers[TapAndHorizontalDragGestureRecognizer] = + GestureRecognizerFactoryWithHandlers< + TapAndHorizontalDragGestureRecognizer>( + () => TapAndHorizontalDragGestureRecognizer( + debugOwner: this, + supportedDevices: PointerDeviceKind.values + .where( + (PointerDeviceKind device) => device != PointerDeviceKind.mouse) + .toSet(), + ), + (TapAndHorizontalDragGestureRecognizer instance) { + instance + // iOS does not provide a device specific touch slop + // unlike Android (~8.0), so the touch slop for a [Scrollable] + // always default to kTouchSlop which is 18.0. When + // [SelectableRegion] is the child of a horizontal + // scrollable that means the [SelectableRegion] will + // always win the gesture arena when competing with + // the ancestor scrollable because they both have + // the same touch slop threshold and the child receives + // the [PointerEvent] first. To avoid this conflict + // and ensure a smooth scrolling experience, on + // iOS the [TapAndHorizontalDragGestureRecognizer] + // will wait for all other gestures to lose before + // declaring victory. + ..eagerVictoryOnDrag = defaultTargetPlatform != TargetPlatform.iOS + ..onTapDown = _startNewMouseSelectionGesture + ..onTapUp = _handleMouseTapUp + ..onDragStart = _handleMouseDragStart + ..onDragUpdate = _handleMouseDragUpdate + ..onDragEnd = _handleMouseDragEnd + ..onCancel = clearSelection + ..dragStartBehavior = DragStartBehavior.down; + }, + ); + _gestureRecognizers[LongPressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer( + debugOwner: this, supportedDevices: _kLongPressSelectionDevices), + (LongPressGestureRecognizer instance) { + instance + ..onLongPressStart = _handleTouchLongPressStart + ..onLongPressMoveUpdate = _handleTouchLongPressMoveUpdate + ..onLongPressEnd = _handleTouchLongPressEnd; + }, + ); + } + + Offset? _doubleTapOffset; + + void _startNewMouseSelectionGesture(TapDragDownDetails details) { + _lastPointerDeviceKind = details.kind; + switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) { + case 1: + widget.focusNode.requestFocus(); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + // On mobile platforms the selection is set on tap up for the first + // tap. + break; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + hideToolbar(); + // It is impossible to extend the selection when the shift key is + // pressed and the start of the selection has not been initialized. + // In this case we fallback on collapsing the selection to first + // initialize the selection. + final isShiftPressedValid = _isShiftPressed && + _selectionDelegate.value.startSelectionPoint != null; + if (isShiftPressedValid) { + _selectEndTo(offset: details.globalPosition); + return; + } + _collapseSelectionAt(offset: details.globalPosition); + } + case 2: + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + if (kIsWeb && + details.kind != null && + !_isPrecisePointerDevice(details.kind!)) { + // Double tap on iOS web triggers when a drag begins after the double tap. + _doubleTapOffset = details.globalPosition; + break; + } + _selectWordAt(offset: details.globalPosition); + if (details.kind != null && + !_isPrecisePointerDevice(details.kind!)) { + _showHandles(); + } + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + _selectWordAt(offset: details.globalPosition); + } + case 3: + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + if (details.kind != null && + _isPrecisePointerDevice(details.kind!)) { + // Triple tap on static text is only supported on mobile + // platforms using a precise pointer device. + _selectParagraphAt(offset: details.globalPosition); + } + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + _selectParagraphAt(offset: details.globalPosition); + } + } + _updateSelectedContentIfNeeded(); + } + + void _handleMouseDragStart(TapDragStartDetails details) { + switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) { + case 1: + if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) { + // Drag to select is only enabled with a precise pointer device. + return; + } + _selectStartTo(offset: details.globalPosition); + } + _updateSelectedContentIfNeeded(); + } + + void _handleMouseDragUpdate(TapDragUpdateDetails details) { + switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) { + case 1: + if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) { + // Drag to select is only enabled with a precise pointer device. + return; + } + _selectEndTo(offset: details.globalPosition, continuous: true); + case 2: + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + // Double tap + drag is only supported on Android when using a precise + // pointer device or when not on the web. + if (!kIsWeb || + details.kind != null && + _isPrecisePointerDevice(details.kind!)) { + _selectEndTo( + offset: details.globalPosition, + continuous: true, + textGranularity: TextGranularity.word); + } + case TargetPlatform.iOS: + if (kIsWeb && + details.kind != null && + !_isPrecisePointerDevice(details.kind!) && + _doubleTapOffset != null) { + // On iOS web a double tap does not select the word at the position, + // until the drag has begun. + _selectWordAt(offset: _doubleTapOffset!); + _doubleTapOffset = null; + } + _selectEndTo( + offset: details.globalPosition, + continuous: true, + textGranularity: TextGranularity.word); + if (details.kind != null && + !_isPrecisePointerDevice(details.kind!)) { + _showHandles(); + } + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + _selectEndTo( + offset: details.globalPosition, + continuous: true, + textGranularity: TextGranularity.word); + } + case 3: + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + // Triple tap + drag is only supported on mobile devices when using + // a precise pointer device. + if (details.kind != null && + _isPrecisePointerDevice(details.kind!)) { + _selectEndTo( + offset: details.globalPosition, + continuous: true, + textGranularity: TextGranularity.paragraph); + } + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + _selectEndTo( + offset: details.globalPosition, + continuous: true, + textGranularity: TextGranularity.paragraph); + } + } + _updateSelectedContentIfNeeded(); + } + + void _handleMouseDragEnd(TapDragEndDetails details) { + final isPointerPrecise = _lastPointerDeviceKind != null && + _lastPointerDeviceKind == PointerDeviceKind.mouse; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + if (!isPointerPrecise) { + // On Android, a drag gesture will only show the selection overlay when + // the drag has finished and the pointer device kind is not precise. + _showHandles(); + _showToolbar(); + } + case TargetPlatform.iOS: + if (!isPointerPrecise) { + // On iOS, a drag gesture will only show the selection toolbar when + // the drag has finished and the pointer device kind is not precise. + _showToolbar(); + } + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + // The selection overlay is not shown on desktop platforms after a drag. + break; + } + _finalizeSelection(); + _updateSelectedContentIfNeeded(); + } + + void _handleMouseTapUp(TapDragUpDetails details) { + if (defaultTargetPlatform == TargetPlatform.iOS && + _positionIsOnActiveSelection(globalPosition: details.globalPosition)) { + // On iOS when the tap occurs on the previous selection, instead of + // moving the selection, the context menu will be toggled. + final toolbarIsVisible = _selectionOverlay?.toolbarIsVisible ?? false; + if (toolbarIsVisible) { + hideToolbar(false); + } else { + _showToolbar(); + } + return; + } + switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) { + case 1: + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + hideToolbar(); + _collapseSelectionAt(offset: details.globalPosition); + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + // On desktop platforms the selection is set on tap down. + break; + } + case 2: + final isPointerPrecise = _isPrecisePointerDevice(details.kind); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + if (!isPointerPrecise) { + // On Android, a double tap will only show the selection overlay after + // the following tap up when the pointer device kind is not precise. + _showHandles(); + _showToolbar(); + } + case TargetPlatform.iOS: + if (!isPointerPrecise) { + // On iOS, a double tap will only show the selection toolbar after + // the following tap up when the pointer device kind is not precise. + _showToolbar(); + } + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + // The selection overlay is not shown on desktop platforms + // on a double click. + break; + } + } + _updateSelectedContentIfNeeded(); + } + + void _updateSelectedContentIfNeeded() { + if (_lastSelectedContent?.plainText != + _selectable?.getSelectedContent()?.plainText) { + _lastSelectedContent = _selectable?.getSelectedContent(); + widget.onSelectionChanged?.call(_lastSelectedContent); + } + } + + void _handleTouchLongPressStart(LongPressStartDetails details) { + HapticFeedback.selectionClick(); + widget.focusNode.requestFocus(); + _selectWordAt(offset: details.globalPosition); + // Platforms besides Android will show the text selection handles when + // the long press is initiated. Android shows the text selection handles when + // the long press has ended, usually after a pointer up event is received. + if (defaultTargetPlatform != TargetPlatform.android) { + _showHandles(); + } + _updateSelectedContentIfNeeded(); + } + + void _handleTouchLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + _selectEndTo( + offset: details.globalPosition, textGranularity: TextGranularity.word); + _updateSelectedContentIfNeeded(); + } + + void _handleTouchLongPressEnd(LongPressEndDetails details) { + _finalizeSelection(); + _updateSelectedContentIfNeeded(); + _showToolbar(); + if (defaultTargetPlatform == TargetPlatform.android) { + _showHandles(); + } + } + + bool _positionIsOnActiveSelection({required Offset globalPosition}) { + for (final selectionRect in _selectionDelegate.value.selectionRects) { + final transform = _selectable!.getTransformTo(null); + final globalRect = MatrixUtils.transformRect(transform, selectionRect); + if (globalRect.contains(globalPosition)) { + return true; + } + } + return false; + } + + void _handleRightClickDown(TapDownDetails details) { + final previousSecondaryTapDownPosition = _lastSecondaryTapDownPosition; + final toolbarIsVisible = _selectionOverlay?.toolbarIsVisible ?? false; + _lastSecondaryTapDownPosition = details.globalPosition; + widget.focusNode.requestFocus(); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + // If _lastSecondaryTapDownPosition is within the current selection then + // keep the current selection, if not then collapse it. + final lastSecondaryTapDownPositionWasOnActiveSelection = + _positionIsOnActiveSelection( + globalPosition: details.globalPosition); + if (!lastSecondaryTapDownPositionWasOnActiveSelection) { + _collapseSelectionAt(offset: _lastSecondaryTapDownPosition!); + } + _showHandles(); + _showToolbar(location: _lastSecondaryTapDownPosition); + case TargetPlatform.iOS: + _selectWordAt(offset: _lastSecondaryTapDownPosition!); + _showHandles(); + _showToolbar(location: _lastSecondaryTapDownPosition); + case TargetPlatform.macOS: + if (previousSecondaryTapDownPosition == _lastSecondaryTapDownPosition && + toolbarIsVisible) { + hideToolbar(); + return; + } + _selectWordAt(offset: _lastSecondaryTapDownPosition!); + _showHandles(); + _showToolbar(location: _lastSecondaryTapDownPosition); + case TargetPlatform.linux: + if (toolbarIsVisible) { + hideToolbar(); + return; + } + // If _lastSecondaryTapDownPosition is within the current selection then + // keep the current selection, if not then collapse it. + final lastSecondaryTapDownPositionWasOnActiveSelection = + _positionIsOnActiveSelection( + globalPosition: details.globalPosition); + if (!lastSecondaryTapDownPositionWasOnActiveSelection) { + _collapseSelectionAt(offset: _lastSecondaryTapDownPosition!); + } + _showHandles(); + _showToolbar(location: _lastSecondaryTapDownPosition); + } + _updateSelectedContentIfNeeded(); + } + + // Selection update helper methods. + + Offset? _selectionEndPosition; + + bool get _userDraggingSelectionEnd => _selectionEndPosition != null; + bool _scheduledSelectionEndEdgeUpdate = false; + + /// Sends end [SelectionEdgeUpdateEvent] to the selectable subtree. + /// + /// If the selectable subtree returns a [SelectionResult.pending], this method + /// continues to send [SelectionEdgeUpdateEvent]s every frame until the result + /// is not pending or users end their gestures. + void _triggerSelectionEndEdgeUpdate({TextGranularity? textGranularity}) { + // This method can be called when the drag is not in progress. This can + // happen if the child scrollable returns SelectionResult.pending, and + // the selection area scheduled a selection update for the next frame, but + // the drag is lifted before the scheduled selection update is run. + if (_scheduledSelectionEndEdgeUpdate || !_userDraggingSelectionEnd) { + return; + } + if (_selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd( + globalPosition: _selectionEndPosition!, + granularity: textGranularity)) == + SelectionResult.pending) { + _scheduledSelectionEndEdgeUpdate = true; + SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { + if (!_scheduledSelectionEndEdgeUpdate) { + return; + } + _scheduledSelectionEndEdgeUpdate = false; + _triggerSelectionEndEdgeUpdate(textGranularity: textGranularity); + }, debugLabel: 'SelectableRegion.endEdgeUpdate'); + return; + } + } + + void _onAnyDragEnd(DragEndDetails details) { + if (widget.selectionControls is! TextSelectionHandleControls) { + _selectionOverlay!.hideMagnifier(); + _selectionOverlay!.showToolbar(); + } else { + _selectionOverlay!.hideMagnifier(); + _selectionOverlay!.showToolbar( + context: context, + contextMenuBuilder: (BuildContext context) => + widget.contextMenuBuilder!(context, this), + ); + } + _stopSelectionStartEdgeUpdate(); + _stopSelectionEndEdgeUpdate(); + _updateSelectedContentIfNeeded(); + } + + void _stopSelectionEndEdgeUpdate() { + _scheduledSelectionEndEdgeUpdate = false; + _selectionEndPosition = null; + } + + Offset? _selectionStartPosition; + + bool get _userDraggingSelectionStart => _selectionStartPosition != null; + bool _scheduledSelectionStartEdgeUpdate = false; + + /// Sends start [SelectionEdgeUpdateEvent] to the selectable subtree. + /// + /// If the selectable subtree returns a [SelectionResult.pending], this method + /// continues to send [SelectionEdgeUpdateEvent]s every frame until the result + /// is not pending or users end their gestures. + void _triggerSelectionStartEdgeUpdate({TextGranularity? textGranularity}) { + // This method can be called when the drag is not in progress. This can + // happen if the child scrollable returns SelectionResult.pending, and + // the selection area scheduled a selection update for the next frame, but + // the drag is lifted before the scheduled selection update is run. + if (_scheduledSelectionStartEdgeUpdate || !_userDraggingSelectionStart) { + return; + } + if (_selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart( + globalPosition: _selectionStartPosition!, + granularity: textGranularity)) == + SelectionResult.pending) { + _scheduledSelectionStartEdgeUpdate = true; + SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { + if (!_scheduledSelectionStartEdgeUpdate) { + return; + } + _scheduledSelectionStartEdgeUpdate = false; + _triggerSelectionStartEdgeUpdate(textGranularity: textGranularity); + }, debugLabel: 'SelectableRegion.startEdgeUpdate'); + return; + } + } + + void _stopSelectionStartEdgeUpdate() { + _scheduledSelectionStartEdgeUpdate = false; + _selectionEndPosition = null; + } + + // SelectionOverlay helper methods. + + late Offset _selectionStartHandleDragPosition; + late Offset _selectionEndHandleDragPosition; + + void _handleSelectionStartHandleDragStart(DragStartDetails details) { + assert(_selectionDelegate.value.startSelectionPoint != null); + + final localPosition = + _selectionDelegate.value.startSelectionPoint!.localPosition; + final globalTransform = _selectable!.getTransformTo(null); + _selectionStartHandleDragPosition = + MatrixUtils.transformPoint(globalTransform, localPosition); + + _selectionOverlay!.showMagnifier(_buildInfoForMagnifier( + details.globalPosition, + _selectionDelegate.value.startSelectionPoint!, + )); + _updateSelectedContentIfNeeded(); + } + + void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) { + _selectionStartHandleDragPosition = + _selectionStartHandleDragPosition + details.delta; + // The value corresponds to the paint origin of the selection handle. + // Offset it to the center of the line to make it feel more natural. + _selectionStartPosition = _selectionStartHandleDragPosition - + Offset(0, _selectionDelegate.value.startSelectionPoint!.lineHeight / 2); + _triggerSelectionStartEdgeUpdate(); + + _selectionOverlay!.updateMagnifier(_buildInfoForMagnifier( + details.globalPosition, + _selectionDelegate.value.startSelectionPoint!, + )); + _updateSelectedContentIfNeeded(); + } + + void _handleSelectionEndHandleDragStart(DragStartDetails details) { + assert(_selectionDelegate.value.endSelectionPoint != null); + final localPosition = + _selectionDelegate.value.endSelectionPoint!.localPosition; + final globalTransform = _selectable!.getTransformTo(null); + _selectionEndHandleDragPosition = + MatrixUtils.transformPoint(globalTransform, localPosition); + + _selectionOverlay!.showMagnifier(_buildInfoForMagnifier( + details.globalPosition, + _selectionDelegate.value.endSelectionPoint!, + )); + _updateSelectedContentIfNeeded(); + } + + void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) { + _selectionEndHandleDragPosition = + _selectionEndHandleDragPosition + details.delta; + // The value corresponds to the paint origin of the selection handle. + // Offset it to the center of the line to make it feel more natural. + _selectionEndPosition = _selectionEndHandleDragPosition - + Offset(0, _selectionDelegate.value.endSelectionPoint!.lineHeight / 2); + _triggerSelectionEndEdgeUpdate(); + + _selectionOverlay!.updateMagnifier(_buildInfoForMagnifier( + details.globalPosition, + _selectionDelegate.value.endSelectionPoint!, + )); + _updateSelectedContentIfNeeded(); + } + + MagnifierInfo _buildInfoForMagnifier( + Offset globalGesturePosition, SelectionPoint selectionPoint) { + final globalTransform = _selectable!.getTransformTo(null).getTranslation(); + final globalTransformAsOffset = + Offset(globalTransform.x, globalTransform.y); + final globalSelectionPointPosition = + selectionPoint.localPosition + globalTransformAsOffset; + final caretRect = Rect.fromLTWH( + globalSelectionPointPosition.dx, + globalSelectionPointPosition.dy - selectionPoint.lineHeight, + 0, + selectionPoint.lineHeight); + + return MagnifierInfo( + globalGesturePosition: globalGesturePosition, + caretRect: caretRect, + fieldBounds: globalTransformAsOffset & _selectable!.size, + currentLineBoundaries: globalTransformAsOffset & _selectable!.size, + ); + } + + void _createSelectionOverlay() { + assert(_hasSelectionOverlayGeometry); + if (_selectionOverlay != null) { + return; + } + final start = _selectionDelegate.value.startSelectionPoint; + final end = _selectionDelegate.value.endSelectionPoint; + _selectionOverlay = SelectionOverlay( + context: context, + debugRequiredFor: widget, + startHandleType: start?.handleType ?? TextSelectionHandleType.left, + lineHeightAtStart: start?.lineHeight ?? end!.lineHeight, + onStartHandleDragStart: _handleSelectionStartHandleDragStart, + onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate, + onStartHandleDragEnd: _onAnyDragEnd, + endHandleType: end?.handleType ?? TextSelectionHandleType.right, + lineHeightAtEnd: end?.lineHeight ?? start!.lineHeight, + onEndHandleDragStart: _handleSelectionEndHandleDragStart, + onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate, + onEndHandleDragEnd: _onAnyDragEnd, + selectionEndpoints: selectionEndpoints, + selectionControls: widget.selectionControls, + selectionDelegate: this, + clipboardStatus: null, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + toolbarLayerLink: _toolbarLayerLink, + magnifierConfiguration: widget.magnifierConfiguration); + } + + void _updateSelectionOverlay() { + if (_selectionOverlay == null) { + return; + } + assert(_hasSelectionOverlayGeometry); + final start = _selectionDelegate.value.startSelectionPoint; + final end = _selectionDelegate.value.endSelectionPoint; + _selectionOverlay! + ..startHandleType = start?.handleType ?? TextSelectionHandleType.left + ..lineHeightAtStart = start?.lineHeight ?? end!.lineHeight + ..endHandleType = end?.handleType ?? TextSelectionHandleType.right + ..lineHeightAtEnd = end?.lineHeight ?? start!.lineHeight + ..selectionEndpoints = selectionEndpoints; + } + + /// Shows the selection handles. + /// + /// Returns true if the handles are shown, false if the handles can't be + /// shown. + bool _showHandles() { + if (_selectionOverlay != null) { + _selectionOverlay!.showHandles(); + return true; + } + + if (!_hasSelectionOverlayGeometry) { + return false; + } + + _createSelectionOverlay(); + _selectionOverlay!.showHandles(); + return true; + } + + /// Shows the text selection toolbar. + /// + /// If the parameter `location` is set, the toolbar will be shown at the + /// location. Otherwise, the toolbar location will be calculated based on the + /// handles' locations. The `location` is in the coordinates system of the + /// [Overlay]. + /// + /// Returns true if the toolbar is shown, false if the toolbar can't be shown. + bool _showToolbar({Offset? location}) { + if (!_hasSelectionOverlayGeometry && _selectionOverlay == null) { + return false; + } + + // Web is using native dom elements to enable clipboard functionality of the + // context menu: copy, paste, select, cut. It might also provide additional + // functionality depending on the browser (such as translate). Due to this, + // we should not show a Flutter toolbar for the editable text elements + // unless the browser's context menu is explicitly disabled. + if (kIsWeb && BrowserContextMenu.enabled) { + return false; + } + + if (_selectionOverlay == null) { + _createSelectionOverlay(); + } + + _selectionOverlay!.toolbarLocation = location; + if (widget.selectionControls is! TextSelectionHandleControls) { + _selectionOverlay!.showToolbar(); + return true; + } + + _selectionOverlay!.hideToolbar(); + + _selectionOverlay!.showToolbar( + context: context, + contextMenuBuilder: (BuildContext context) => + widget.contextMenuBuilder!(context, this), + ); + return true; + } + + /// Sets or updates selection end edge to the `offset` location. + /// + /// A selection always contains a select start edge and selection end edge. + /// They can be created by calling both [_selectStartTo] and [_selectEndTo], or + /// use other selection APIs, such as [_selectWordAt] or [selectAll]. + /// + /// This method sets or updates the selection end edge by sending + /// [SelectionEdgeUpdateEvent]s to the child [Selectable]s. + /// + /// If `continuous` is set to true and the update causes scrolling, the + /// method will continue sending the same [SelectionEdgeUpdateEvent]s to the + /// child [Selectable]s every frame until the scrolling finishes or a + /// [_finalizeSelection] is called. + /// + /// The `continuous` argument defaults to false. + /// + /// The `offset` is in global coordinates. + /// + /// Provide the `textGranularity` if the selection should not move by the default + /// [TextGranularity.character]. Only [TextGranularity.character] and + /// [TextGranularity.word] are currently supported. + /// + /// See also: + /// * [_selectStartTo], which sets or updates selection start edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [clearSelection], which clears the ongoing selection. + /// * [_selectWordAt], which selects a whole word at the location. + /// * [_selectParagraphAt], which selects an entire paragraph at the location. + /// * [_collapseSelectionAt], which collapses the selection at the location. + /// * [selectAll], which selects the entire content. + void _selectEndTo( + {required Offset offset, + bool continuous = false, + TextGranularity? textGranularity}) { + if (!continuous) { + _selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd( + globalPosition: offset, granularity: textGranularity)); + return; + } + if (_selectionEndPosition != offset) { + _selectionEndPosition = offset; + _triggerSelectionEndEdgeUpdate(textGranularity: textGranularity); + } + } + + /// Sets or updates selection start edge to the `offset` location. + /// + /// A selection always contains a select start edge and selection end edge. + /// They can be created by calling both [_selectStartTo] and [_selectEndTo], or + /// use other selection APIs, such as [_selectWordAt] or [selectAll]. + /// + /// This method sets or updates the selection start edge by sending + /// [SelectionEdgeUpdateEvent]s to the child [Selectable]s. + /// + /// If `continuous` is set to true and the update causes scrolling, the + /// method will continue sending the same [SelectionEdgeUpdateEvent]s to the + /// child [Selectable]s every frame until the scrolling finishes or a + /// [_finalizeSelection] is called. + /// + /// The `continuous` argument defaults to false. + /// + /// The `offset` is in global coordinates. + /// + /// Provide the `textGranularity` if the selection should not move by the default + /// [TextGranularity.character]. Only [TextGranularity.character] and + /// [TextGranularity.word] are currently supported. + /// + /// See also: + /// * [_selectEndTo], which sets or updates selection end edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [clearSelection], which clears the ongoing selection. + /// * [_selectWordAt], which selects a whole word at the location. + /// * [_selectParagraphAt], which selects an entire paragraph at the location. + /// * [_collapseSelectionAt], which collapses the selection at the location. + /// * [selectAll], which selects the entire content. + void _selectStartTo( + {required Offset offset, + bool continuous = false, + TextGranularity? textGranularity}) { + if (!continuous) { + _selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart( + globalPosition: offset, granularity: textGranularity)); + return; + } + if (_selectionStartPosition != offset) { + _selectionStartPosition = offset; + _triggerSelectionStartEdgeUpdate(textGranularity: textGranularity); + } + } + + /// Collapses the selection at the given `offset` location. + /// + /// The `offset` is in global coordinates. + /// + /// See also: + /// * [_selectStartTo], which sets or updates selection start edge. + /// * [_selectEndTo], which sets or updates selection end edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [clearSelection], which clears the ongoing selection. + /// * [_selectWordAt], which selects a whole word at the location. + /// * [_selectParagraphAt], which selects an entire paragraph at the location. + /// * [selectAll], which selects the entire content. + void _collapseSelectionAt({required Offset offset}) { + _selectStartTo(offset: offset); + _selectEndTo(offset: offset); + } + + /// Selects a whole word at the `offset` location. + /// + /// The `offset` is in global coordinates. + /// + /// If the whole word is already in the current selection, selection won't + /// change. One call [clearSelection] first if the selection needs to be + /// updated even if the word is already covered by the current selection. + /// + /// One can also use [_selectEndTo] or [_selectStartTo] to adjust the selection + /// edges after calling this method. + /// + /// See also: + /// * [_selectStartTo], which sets or updates selection start edge. + /// * [_selectEndTo], which sets or updates selection end edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [clearSelection], which clears the ongoing selection. + /// * [_collapseSelectionAt], which collapses the selection at the location. + /// * [_selectParagraphAt], which selects an entire paragraph at the location. + /// * [selectAll], which selects the entire content. + void _selectWordAt({required Offset offset}) { + // There may be other selection ongoing. + _finalizeSelection(); + _selectable?.dispatchSelectionEvent( + SelectWordSelectionEvent(globalPosition: offset)); + } + + /// Selects the entire paragraph at the `offset` location. + /// + /// The `offset` is in global coordinates. + /// + /// If the paragraph is already in the current selection, selection won't + /// change. One call [clearSelection] first if the selection needs to be + /// updated even if the paragraph is already covered by the current selection. + /// + /// One can also use [_selectEndTo] or [_selectStartTo] to adjust the selection + /// edges after calling this method. + /// + /// See also: + /// * [_selectStartTo], which sets or updates selection start edge. + /// * [_selectEndTo], which sets or updates selection end edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [clearSelection], which clear the ongoing selection. + /// * [_selectWordAt], which selects a whole word at the location. + /// * [selectAll], which selects the entire content. + void _selectParagraphAt({required Offset offset}) { + // There may be other selection ongoing. + _finalizeSelection(); + _selectable?.dispatchSelectionEvent( + SelectParagraphSelectionEvent(globalPosition: offset)); + } + + /// Stops any ongoing selection updates. + /// + /// This method is different from [clearSelection] that it does not remove + /// the current selection. It only stops the continuous updates. + /// + /// A continuous update can happen as result of calling [_selectStartTo] or + /// [_selectEndTo] with `continuous` sets to true which causes a [Selectable] + /// to scroll. Calling this method will stop the update as well as the + /// scrolling. + void _finalizeSelection() { + _stopSelectionEndEdgeUpdate(); + _stopSelectionStartEdgeUpdate(); + } + + /// Removes the ongoing selection for this [TSelectableRegion]. + void clearSelection() { + _finalizeSelection(); + _directionalHorizontalBaseline = null; + _adjustingSelectionEnd = null; + _selectable?.dispatchSelectionEvent(const ClearSelectionEvent()); + _updateSelectedContentIfNeeded(); + } + + Future _copy() async { + final data = _selectable?.getSelectedContent(); + if (data == null) { + return; + } + await Clipboard.setData(ClipboardData(text: data.plainText)); + } + + Future _share() async { + final data = _selectable?.getSelectedContent(); + if (data == null) { + return; + } + await SystemChannels.platform.invokeMethod('Share.invoke', data.plainText); + } + + /// {@macro flutter.widgets.EditableText.getAnchors} + /// + /// See also: + /// + /// * [contextMenuButtonItems], which provides the [ContextMenuButtonItem]s + /// for the default context menu buttons. + TextSelectionToolbarAnchors get contextMenuAnchors { + if (_lastSecondaryTapDownPosition != null) { + return TextSelectionToolbarAnchors( + primaryAnchor: _lastSecondaryTapDownPosition!, + ); + } + final renderBox = context.findRenderObject()! as RenderBox; + return TextSelectionToolbarAnchors.fromSelection( + renderBox: renderBox, + startGlyphHeight: startGlyphHeight, + endGlyphHeight: endGlyphHeight, + selectionEndpoints: selectionEndpoints, + ); + } + + bool? _adjustingSelectionEnd; + + bool _determineIsAdjustingSelectionEnd(bool forward) { + if (_adjustingSelectionEnd != null) { + return _adjustingSelectionEnd!; + } + final bool isReversed; + final start = _selectionDelegate.value.startSelectionPoint!; + final end = _selectionDelegate.value.endSelectionPoint!; + if (start.localPosition.dy > end.localPosition.dy) { + isReversed = true; + } else if (start.localPosition.dy < end.localPosition.dy) { + isReversed = false; + } else { + isReversed = start.localPosition.dx > end.localPosition.dx; + } + // Always move the selection edge that increases the selection range. + return _adjustingSelectionEnd = forward != isReversed; + } + + void _granularlyExtendSelection(TextGranularity granularity, bool forward) { + _directionalHorizontalBaseline = null; + if (!_selectionDelegate.value.hasSelection) { + return; + } + _selectable?.dispatchSelectionEvent( + GranularlyExtendSelectionEvent( + forward: forward, + isEnd: _determineIsAdjustingSelectionEnd(forward), + granularity: granularity, + ), + ); + _updateSelectedContentIfNeeded(); + } + + double? _directionalHorizontalBaseline; + + void _directionallyExtendSelection(bool forward) { + if (!_selectionDelegate.value.hasSelection) { + return; + } + final adjustingSelectionExtend = _determineIsAdjustingSelectionEnd(forward); + final baseLinePoint = adjustingSelectionExtend + ? _selectionDelegate.value.endSelectionPoint! + : _selectionDelegate.value.startSelectionPoint!; + _directionalHorizontalBaseline ??= baseLinePoint.localPosition.dx; + final globalSelectionPointOffset = MatrixUtils.transformPoint( + context.findRenderObject()!.getTransformTo(null), + Offset(_directionalHorizontalBaseline!, 0)); + _selectable?.dispatchSelectionEvent( + DirectionallyExtendSelectionEvent( + isEnd: _adjustingSelectionEnd!, + direction: forward + ? SelectionExtendDirection.nextLine + : SelectionExtendDirection.previousLine, + dx: globalSelectionPointOffset.dx, + ), + ); + _updateSelectedContentIfNeeded(); + } + + // [TextSelectionDelegate] overrides. + + /// Returns the [ContextMenuButtonItem]s representing the buttons in this + /// platform's default selection menu. + /// + /// See also: + /// + /// * [TSelectableRegion.getSelectableButtonItems], which performs a similar role, + /// but for any selectable text, not just specifically SelectableRegion. + /// * [EditableTextState.contextMenuButtonItems], which performs a similar role + /// but for content that is not just selectable but also editable. + /// * [contextMenuAnchors], which provides the anchor points for the default + /// context menu. + /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can + /// take a list of [ContextMenuButtonItem]s with + /// [AdaptiveTextSelectionToolbar.buttonItems]. + /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the + /// button Widgets for the current platform given [ContextMenuButtonItem]s. + List get contextMenuButtonItems => + TSelectableRegion.getSelectableButtonItems( + selectionGeometry: _selectionDelegate.value, + onCopy: () { + _copy(); + + // On Android copy should clear the selection. + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + clearSelection(); + case TargetPlatform.iOS: + hideToolbar(false); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + hideToolbar(); + } + }, + onSelectAll: () { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + selectAll(SelectionChangedCause.toolbar); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + selectAll(); + hideToolbar(); + } + }, + onShare: () { + _share(); + + // On Android, share should clear the selection. + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + clearSelection(); + case TargetPlatform.iOS: + hideToolbar(false); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + hideToolbar(); + } + }, + )..addAll(_textProcessingActionButtonItems); + + List get _textProcessingActionButtonItems { + final buttonItems = []; + final data = _selectable?.getSelectedContent(); + if (data == null) { + return buttonItems; + } + + for (final action in _processTextActions) { + buttonItems.add(ContextMenuButtonItem( + label: action.label, + onPressed: () async { + final selectedText = data.plainText; + if (selectedText.isNotEmpty) { + await _processTextService.processTextAction( + action.id, selectedText, true); + hideToolbar(); + } + }, + )); + } + return buttonItems; + } + + /// The line height at the start of the current selection. + double get startGlyphHeight => + _selectionDelegate.value.startSelectionPoint!.lineHeight; + + /// The line height at the end of the current selection. + double get endGlyphHeight => + _selectionDelegate.value.endSelectionPoint!.lineHeight; + + /// Returns the local coordinates of the endpoints of the current selection. + List get selectionEndpoints { + final start = _selectionDelegate.value.startSelectionPoint; + final end = _selectionDelegate.value.endSelectionPoint; + late List points; + final startLocalPosition = start?.localPosition ?? end!.localPosition; + final endLocalPosition = end?.localPosition ?? start!.localPosition; + if (startLocalPosition.dy > endLocalPosition.dy) { + points = [ + TextSelectionPoint(endLocalPosition, TextDirection.ltr), + TextSelectionPoint(startLocalPosition, TextDirection.ltr), + ]; + } else { + points = [ + TextSelectionPoint(startLocalPosition, TextDirection.ltr), + TextSelectionPoint(endLocalPosition, TextDirection.ltr), + ]; + } + return points; + } + + // [TextSelectionDelegate] overrides. + // TODO(justinmc): After deprecations have been removed, remove + // TextSelectionDelegate from this class. + // https://github.com/flutter/flutter/issues/111213 + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + bool get cutEnabled => false; + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + bool get pasteEnabled => false; + + @override + void hideToolbar([bool hideHandles = true]) { + _selectionOverlay?.hideToolbar(); + if (hideHandles) { + _selectionOverlay?.hideHandles(); + } + } + + @override + void selectAll([SelectionChangedCause? cause]) { + clearSelection(); + _selectable?.dispatchSelectionEvent(const SelectAllSelectionEvent()); + if (cause == SelectionChangedCause.toolbar) { + _showToolbar(); + _showHandles(); + } + _updateSelectedContentIfNeeded(); + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + void copySelection(SelectionChangedCause cause) { + _copy(); + clearSelection(); + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + TextEditingValue textEditingValue = const TextEditingValue(text: '_'); + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + void bringIntoView(TextPosition position) { + /* SelectableRegion must be in view at this point. */ + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + void cutSelection(SelectionChangedCause cause) { + assert(false); + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + void userUpdateTextEditingValue( + TextEditingValue value, SelectionChangedCause cause) { + /* SelectableRegion maintains its own state */ + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + Future pasteText(SelectionChangedCause cause) async { + assert(false); + } + + // [SelectionRegistrar] override. + + @override + void add(Selectable selectable) { + assert(_selectable == null); + _selectable = selectable; + _selectable!.addListener(_updateSelectionStatus); + _selectable!.pushHandleLayers(_startHandleLayerLink, _endHandleLayerLink); + } + + @override + void remove(Selectable selectable) { + assert(_selectable == selectable); + _selectable!.removeListener(_updateSelectionStatus); + _selectable!.pushHandleLayers(null, null); + _selectable = null; + } + + @override + void dispose() { + _selectable?.removeListener(_updateSelectionStatus); + _selectable?.pushHandleLayers(null, null); + _selectionDelegate.dispose(); + // In case dispose was triggered before gesture end, remove the magnifier + // so it doesn't remain stuck in the overlay forever. + _selectionOverlay?.hideMagnifier(); + _selectionOverlay?.dispose(); + _selectionOverlay = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasOverlay(context)); + Widget result = SelectionContainer( + registrar: this, + delegate: _selectionDelegate, + child: widget.child, + ); + if (kIsWeb) { + result = PlatformSelectableRegionContextMenu( + child: result, + ); + } + return CompositedTransformTarget( + link: _toolbarLayerLink, + child: RawGestureDetector( + gestures: _gestureRecognizers, + behavior: HitTestBehavior.translucent, + excludeFromSemantics: true, + child: Actions( + actions: _actions, + child: Focus( + includeSemantics: false, + focusNode: widget.focusNode, + child: result, + ), + ), + ), + ); + } +} + +/// An action that does not override any [Action.overridable] in the subtree. +/// +/// If this action is invoked by an [Action.overridable], it will immediately +/// invoke the [Action.overridable] and do nothing else. Otherwise, it will call +/// [invokeAction]. +abstract class _NonOverrideAction extends ContextAction { + Object? invokeAction(T intent, [BuildContext? context]); + + @override + Object? invoke(T intent, [BuildContext? context]) { + if (callingAction != null) { + return callingAction!.invoke(intent); + } + return invokeAction(intent, context); + } +} + +class _SelectAllAction extends _NonOverrideAction { + _SelectAllAction(this.state); + + final TSelectableRegionState state; + + @override + void invokeAction(SelectAllTextIntent intent, [BuildContext? context]) { + state.selectAll(SelectionChangedCause.keyboard); + } +} + +class _CopySelectionAction extends _NonOverrideAction { + _CopySelectionAction(this.state); + + final TSelectableRegionState state; + + @override + void invokeAction(CopySelectionTextIntent intent, [BuildContext? context]) { + state._copy(); + } +} + +class _GranularlyExtendSelectionAction + extends _NonOverrideAction { + _GranularlyExtendSelectionAction(this.state, {required this.granularity}); + + final TSelectableRegionState state; + final TextGranularity granularity; + + @override + void invokeAction(T intent, [BuildContext? context]) { + state._granularlyExtendSelection(granularity, intent.forward); + } +} + +class _GranularlyExtendCaretSelectionAction< + T extends DirectionalCaretMovementIntent> extends _NonOverrideAction { + _GranularlyExtendCaretSelectionAction(this.state, + {required this.granularity}); + + final TSelectableRegionState state; + final TextGranularity granularity; + + @override + void invokeAction(T intent, [BuildContext? context]) { + if (intent.collapseSelection) { + // Selectable region never collapses selection. + return; + } + state._granularlyExtendSelection(granularity, intent.forward); + } +} + +class _DirectionallyExtendCaretSelectionAction< + T extends DirectionalCaretMovementIntent> extends _NonOverrideAction { + _DirectionallyExtendCaretSelectionAction(this.state); + + final TSelectableRegionState state; + + @override + void invokeAction(T intent, [BuildContext? context]) { + if (intent.collapseSelection) { + // Selectable region never collapses selection. + return; + } + state._directionallyExtendSelection(intent.forward); + } +} + +class _SelectableRegionContainerDelegate + extends MultiSelectableSelectionContainerDelegate { + final Set _hasReceivedStartEvent = {}; + final Set _hasReceivedEndEvent = {}; + + Offset? _lastStartEdgeUpdateGlobalPosition; + Offset? _lastEndEdgeUpdateGlobalPosition; + + @override + void remove(Selectable selectable) { + _hasReceivedStartEvent.remove(selectable); + _hasReceivedEndEvent.remove(selectable); + super.remove(selectable); + } + + void _updateLastEdgeEventsFromGeometries() { + if (currentSelectionStartIndex != -1 && + selectables[currentSelectionStartIndex].value.hasSelection) { + final start = selectables[currentSelectionStartIndex]; + final localStartEdge = start.value.startSelectionPoint!.localPosition + + Offset(0, -start.value.startSelectionPoint!.lineHeight / 2); + _lastStartEdgeUpdateGlobalPosition = MatrixUtils.transformPoint( + start.getTransformTo(null), localStartEdge); + } + if (currentSelectionEndIndex != -1 && + selectables[currentSelectionEndIndex].value.hasSelection) { + final end = selectables[currentSelectionEndIndex]; + final localEndEdge = end.value.endSelectionPoint!.localPosition + + Offset(0, -end.value.endSelectionPoint!.lineHeight / 2); + _lastEndEdgeUpdateGlobalPosition = + MatrixUtils.transformPoint(end.getTransformTo(null), localEndEdge); + } + } + + @override + SelectionResult handleSelectAll(SelectAllSelectionEvent event) { + final result = super.handleSelectAll(event); + for (final selectable in selectables) { + _hasReceivedStartEvent.add(selectable); + _hasReceivedEndEvent.add(selectable); + } + // Synthesize last update event so the edge updates continue to work. + _updateLastEdgeEventsFromGeometries(); + return result; + } + + /// Selects a word in a [Selectable] at the location + /// [SelectWordSelectionEvent.globalPosition]. + @override + SelectionResult handleSelectWord(SelectWordSelectionEvent event) { + final result = super.handleSelectWord(event); + if (currentSelectionStartIndex != -1) { + _hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]); + } + if (currentSelectionEndIndex != -1) { + _hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]); + } + _updateLastEdgeEventsFromGeometries(); + return result; + } + + /// Selects a paragraph in a [Selectable] at the location + /// [SelectParagraphSelectionEvent.globalPosition]. + @override + SelectionResult handleSelectParagraph(SelectParagraphSelectionEvent event) { + final result = super.handleSelectParagraph(event); + if (currentSelectionStartIndex != -1) { + _hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]); + } + if (currentSelectionEndIndex != -1) { + _hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]); + } + _updateLastEdgeEventsFromGeometries(); + return result; + } + + @override + SelectionResult handleClearSelection(ClearSelectionEvent event) { + final result = super.handleClearSelection(event); + _hasReceivedStartEvent.clear(); + _hasReceivedEndEvent.clear(); + _lastStartEdgeUpdateGlobalPosition = null; + _lastEndEdgeUpdateGlobalPosition = null; + return result; + } + + @override + SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) { + if (event.type == SelectionEventType.endEdgeUpdate) { + _lastEndEdgeUpdateGlobalPosition = event.globalPosition; + } else { + _lastStartEdgeUpdateGlobalPosition = event.globalPosition; + } + return super.handleSelectionEdgeUpdate(event); + } + + @override + void dispose() { + _hasReceivedStartEvent.clear(); + _hasReceivedEndEvent.clear(); + super.dispose(); + } + + @override + SelectionResult dispatchSelectionEventToChild( + Selectable selectable, SelectionEvent event) { + switch (event.type) { + case SelectionEventType.startEdgeUpdate: + _hasReceivedStartEvent.add(selectable); + ensureChildUpdated(selectable); + case SelectionEventType.endEdgeUpdate: + _hasReceivedEndEvent.add(selectable); + ensureChildUpdated(selectable); + case SelectionEventType.clear: + _hasReceivedStartEvent.remove(selectable); + _hasReceivedEndEvent.remove(selectable); + case SelectionEventType.selectAll: + case SelectionEventType.selectWord: + case SelectionEventType.selectParagraph: + break; + case SelectionEventType.granularlyExtendSelection: + case SelectionEventType.directionallyExtendSelection: + _hasReceivedStartEvent.add(selectable); + _hasReceivedEndEvent.add(selectable); + ensureChildUpdated(selectable); + } + return super.dispatchSelectionEventToChild(selectable, event); + } + + @override + void ensureChildUpdated(Selectable selectable) { + if (_lastEndEdgeUpdateGlobalPosition != null && + _hasReceivedEndEvent.add(selectable)) { + final synthesizedEvent = SelectionEdgeUpdateEvent.forEnd( + globalPosition: _lastEndEdgeUpdateGlobalPosition!, + ); + if (currentSelectionEndIndex == -1) { + handleSelectionEdgeUpdate(synthesizedEvent); + } + selectable.dispatchSelectionEvent(synthesizedEvent); + } + if (_lastStartEdgeUpdateGlobalPosition != null && + _hasReceivedStartEvent.add(selectable)) { + final synthesizedEvent = SelectionEdgeUpdateEvent.forStart( + globalPosition: _lastStartEdgeUpdateGlobalPosition!, + ); + if (currentSelectionStartIndex == -1) { + handleSelectionEdgeUpdate(synthesizedEvent); + } + selectable.dispatchSelectionEvent(synthesizedEvent); + } + } + + @override + SelectedContent? getSelectedContent() { + final selections = [ + for (final Selectable selectable in selectables) + if (selectable.getSelectedContent() case final data?) data, + ]; + if (selections.isEmpty) { + return null; + } + final buffer = StringBuffer(); + var isFirst = true; + for (final selection in selections) { + if (!isFirst) { + // 10 = '\n' + buffer.writeCharCode(10); + } + buffer.write(selection.plainText); + isFirst = false; + } + return SelectedContent( + plainText: buffer.toString(), + ); + } + + @override + void didChangeSelectables() { + if (_lastEndEdgeUpdateGlobalPosition != null) { + handleSelectionEdgeUpdate( + SelectionEdgeUpdateEvent.forEnd( + globalPosition: _lastEndEdgeUpdateGlobalPosition!, + ), + ); + } + if (_lastStartEdgeUpdateGlobalPosition != null) { + handleSelectionEdgeUpdate( + SelectionEdgeUpdateEvent.forStart( + globalPosition: _lastStartEdgeUpdateGlobalPosition!, + ), + ); + } + final selectableSet = selectables.toSet(); + _hasReceivedEndEvent.removeWhere( + (Selectable selectable) => !selectableSet.contains(selectable)); + _hasReceivedStartEvent.removeWhere( + (Selectable selectable) => !selectableSet.contains(selectable)); + super.didChangeSelectables(); + } +} + +typedef TSelectableRegionContextMenuBuilder = Widget Function( + BuildContext context, + TSelectableRegionState selectableRegionState, +); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_selection/t_selection_area.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_selection/t_selection_area.dart new file mode 100644 index 0000000000..b08af66c3f --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_selection/t_selection_area.dart @@ -0,0 +1,148 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'app.dart'; +/// @docImport 'material_localizations.dart'; +/// @docImport 'selectable_text.dart'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import 't_selectable_region.dart'; + +/// A widget that introduces an area for user selections with adaptive selection +/// controls. +/// +/// This widget creates a [SelectableRegion] with platform-adaptive selection +/// controls. +/// +/// Flutter widgets are not selectable by default. To enable selection for +/// a specific screen, consider wrapping the body of the [Route] with a +/// [TSelectionArea]. +/// +/// The [TSelectionArea] widget must have a [Localizations] ancestor that +/// contains a [MaterialLocalizations] delegate; using the [MaterialApp] widget +/// ensures that such an ancestor is present. +/// +/// {@tool dartpad} +/// This example shows how to make a screen selectable. +/// +/// ** See code in examples/api/lib/material/selection_area/selection_area.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SelectableRegion], which provides an overview of the selection system. +/// * [SelectableText], which enables selection on a single run of text. +class TSelectionArea extends StatefulWidget { + /// Creates a [TSelectionArea]. + /// + /// If [selectionControls] is null, a platform specific one is used. + const TSelectionArea({ + super.key, + this.focusNode, + this.selectionControls, + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.magnifierConfiguration, + this.onSelectionChanged, + required this.child, + }); + + /// The configuration for the magnifier in the selection region. + /// + /// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] + /// on Android, and builds nothing on all other platforms. To suppress the + /// magnifier, consider passing [TextMagnifierConfiguration.disabled]. + /// + /// {@macro flutter.widgets.magnifier.intro} + final TextMagnifierConfiguration? magnifierConfiguration; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// The delegate to build the selection handles and toolbar. + /// + /// If it is null, the platform specific selection control is used. + final TextSelectionControls? selectionControls; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// If not provided, will build a default menu based on the ambient + /// [ThemeData.platform]. + /// + /// {@tool dartpad} + /// This example shows how to build a custom context menu for any selected + /// content in a SelectionArea. + /// + /// ** See code in examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar], which is built by default. + final TSelectableRegionContextMenuBuilder? contextMenuBuilder; + + /// Called when the selected content changes. + final ValueChanged? onSelectionChanged; + + /// The child widget this selection area applies to. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + static Widget _defaultContextMenuBuilder( + BuildContext context, TSelectableRegionState selectableRegionState) => + AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: selectableRegionState.contextMenuButtonItems, + anchors: selectableRegionState.contextMenuAnchors); + + @override + State createState() => TSelectionAreaState(); +} + +/// State for a [TSelectionArea]. +class TSelectionAreaState extends State { + FocusNode get _effectiveFocusNode => + widget.focusNode ?? (_internalNode ??= FocusNode()); + FocusNode? _internalNode; + final GlobalKey _selectableRegionKey = + GlobalKey(); + + /// The [State] of the [SelectableRegion] for which this [TSelectionArea] wraps. + SelectableRegionState get selectableRegion => + _selectableRegionKey.currentState!; + + @override + void dispose() { + _internalNode?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final controls = widget.selectionControls ?? + switch (Theme.of(context).platform) { + TargetPlatform.android || + TargetPlatform.fuchsia => + materialTextSelectionHandleControls, + TargetPlatform.linux || + TargetPlatform.windows => + desktopTextSelectionHandleControls, + TargetPlatform.iOS => cupertinoTextSelectionHandleControls, + TargetPlatform.macOS => cupertinoDesktopTextSelectionHandleControls, + }; + return TSelectableRegion( + key: _selectableRegionKey, + selectionControls: controls, + focusNode: _effectiveFocusNode, + contextMenuBuilder: widget.contextMenuBuilder, + magnifierConfiguration: widget.magnifierConfiguration ?? + TextMagnifier.adaptiveMagnifierConfiguration, + onSelectionChanged: widget.onSelectionChanged, + child: widget.child, + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_selection/t_selection_container.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_selection/t_selection_container.dart new file mode 100644 index 0000000000..49fc0c2a77 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_selection/t_selection_container.dart @@ -0,0 +1,57 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +class TSelectionContainer extends StatefulWidget { + TSelectionContainer({super.key, required this.visible, required this.child}); + + final bool visible; + final Widget child; + + @override + State createState() => _TSelectionContainerState(); +} + +class _TSelectionContainerState extends State { + SelectionRegistrar? registrar; + + @override + Widget build(BuildContext context) { + if (widget.visible) { + final scope = context + .dependOnInheritedWidgetOfExactType(); + if (scope == null) { + return widget.child; + } + return SelectionRegistrarScope( + registrar: scope.registrar, child: widget.child); + } + final _registrar = context + .dependOnInheritedWidgetOfExactType() + ?.registrar; + if (_registrar == null) { + return SelectionContainer.disabled(child: widget.child); + } + registrar = _registrar; + return TSelectionRegistrarScope( + registrar: _registrar, + child: SelectionContainer.disabled(child: widget.child)); + } +} + +class TSelectionRegistrarScope extends InheritedWidget { + const TSelectionRegistrarScope({ + super.key, + required this.registrar, + required super.child, + }); + + final SelectionRegistrar registrar; + + @override + bool updateShouldNotify(TSelectionRegistrarScope oldWidget) => + registrar != oldWidget.registrar; + + static SelectionRegistrar? maybeOf(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.registrar; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_shortcut_text_field/t_shortcut_text_field.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_shortcut_text_field/t_shortcut_text_field.dart new file mode 100644 index 0000000000..9b9c995cc1 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_shortcut_text_field/t_shortcut_text_field.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../infra/keyboard/logical_keyboard_key_extensions.dart'; +import '../../../../infra/keyboard/shortcut_extensions.dart'; +import '../../../l10n/view_models/app_localizations_view_model.dart'; +import '../t_text_field/t_text_field.dart'; + +final _allowedKeys = { + // A-Z + LogicalKeyboardKey.keyA, + LogicalKeyboardKey.keyB, + LogicalKeyboardKey.keyC, + LogicalKeyboardKey.keyD, + LogicalKeyboardKey.keyE, + LogicalKeyboardKey.keyF, + LogicalKeyboardKey.keyG, + LogicalKeyboardKey.keyH, + LogicalKeyboardKey.keyI, + LogicalKeyboardKey.keyJ, + LogicalKeyboardKey.keyK, + LogicalKeyboardKey.keyL, + LogicalKeyboardKey.keyM, + LogicalKeyboardKey.keyN, + LogicalKeyboardKey.keyO, + LogicalKeyboardKey.keyP, + LogicalKeyboardKey.keyQ, + LogicalKeyboardKey.keyR, + LogicalKeyboardKey.keyS, + LogicalKeyboardKey.keyT, + LogicalKeyboardKey.keyU, + LogicalKeyboardKey.keyV, + LogicalKeyboardKey.keyW, + LogicalKeyboardKey.keyX, + LogicalKeyboardKey.keyY, + LogicalKeyboardKey.keyZ, + // 0-9 + LogicalKeyboardKey.digit0, + LogicalKeyboardKey.digit1, + LogicalKeyboardKey.digit2, + LogicalKeyboardKey.digit3, + LogicalKeyboardKey.digit4, + LogicalKeyboardKey.digit5, + LogicalKeyboardKey.digit6, + LogicalKeyboardKey.digit7, + LogicalKeyboardKey.digit8, + LogicalKeyboardKey.digit9, + LogicalKeyboardKey.numpad0, + LogicalKeyboardKey.numpad1, + LogicalKeyboardKey.numpad2, + LogicalKeyboardKey.numpad3, + LogicalKeyboardKey.numpad4, + LogicalKeyboardKey.numpad5, + LogicalKeyboardKey.numpad6, + LogicalKeyboardKey.numpad7, + LogicalKeyboardKey.numpad8, + LogicalKeyboardKey.numpad9, + // Special keys + LogicalKeyboardKey.comma, + LogicalKeyboardKey.period, + LogicalKeyboardKey.space, + // Navigation keys + LogicalKeyboardKey.home, + LogicalKeyboardKey.end, + LogicalKeyboardKey.pageUp, + LogicalKeyboardKey.pageDown, + // Editing keys + LogicalKeyboardKey.insert, + LogicalKeyboardKey.delete, + // Arrow keys + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + // Modifier keys + LogicalKeyboardKey.shift, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + LogicalKeyboardKey.alt, + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.altRight, + LogicalKeyboardKey.control, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + LogicalKeyboardKey.meta, + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.metaRight, +}; + +class TShortcutTextField extends ConsumerStatefulWidget { + const TShortcutTextField( + {super.key, this.initialKeys, required this.onShortcutChanged}); + + final List? initialKeys; + final void Function(List keys) onShortcutChanged; + + @override + ConsumerState createState() => + _TShortcutTextFieldState(); +} + +class _TShortcutTextFieldState extends ConsumerState { + late TextEditingController _textEditingController; + late FocusNode _focusNode; + late List _keys; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _textEditingController = TextEditingController(); + final initialKeys = widget.initialKeys; + _keys = initialKeys == null || initialKeys.isEmpty ? [] : initialKeys + ..sortKeys(); + _textEditingController.text = _formatKeys(); + } + + @override + void dispose() { + _focusNode.dispose(); + _textEditingController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(TShortcutTextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialKeys != oldWidget.initialKeys) { + final initialKeys = widget.initialKeys; + _keys = initialKeys == null || initialKeys.isEmpty ? [] : initialKeys + ..sortKeys(); + _textEditingController.text = _formatKeys(); + } + } + + @override + Widget build(BuildContext context) => KeyboardListener( + focusNode: _focusNode, + onKeyEvent: _onKeyEvent, + child: TTextField( + textEditingController: _textEditingController, + showCursor: false, + readOnly: true, + enableInteractiveSelection: false, + ), + ); + + void _onKeyEvent(KeyEvent event) { + switch (event) { + case KeyUpEvent(): + if (HardwareKeyboard.instance.physicalKeysPressed.isNotEmpty) { + return; + } + if (_keys.length == 1) { + _textEditingController.text = + ref.read(appLocalizationsViewModel).none; + widget.onShortcutChanged([]); + } else if (_keys.any((element) => element.isModifier) && + _keys.any((element) => !element.isModifier)) { + widget.onShortcutChanged(_keys); + } else { + _textEditingController.text = + ref.read(appLocalizationsViewModel).none; + widget.onShortcutChanged([]); + } + case KeyDownEvent(): + _keys = HardwareKeyboard.instance.logicalKeysPressed + .map((key) => key.normalizedKey) + .where(_allowedKeys.contains) + .toSet() + .take(4) + .toList() + ..sortKeys(); + _textEditingController.text = _formatKeys(); + } + } + + String _formatKeys() { + if (_keys.isEmpty) { + return ref.read(appLocalizationsViewModel).none; + } + final buffer = StringBuffer(); + for (final key in _keys) { + if (buffer.isNotEmpty) { + buffer.write(' + '); + } + if (key == LogicalKeyboardKey.control) { + buffer.write('Ctrl'); + } else { + buffer.write(key.keyLabel); + } + } + return buffer.toString(); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_switch/t_switch.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_switch/t_switch.dart new file mode 100644 index 0000000000..9d42a18585 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_switch/t_switch.dart @@ -0,0 +1,19 @@ +import 'package:flutter/cupertino.dart'; + +class TSwitch extends StatelessWidget { + const TSwitch({super.key, required this.value, required this.onChanged}); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => SizedBox( + height: 24, + child: FittedBox( + child: CupertinoSwitch( + value: value, + onChanged: onChanged, + ), + ), + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_table/t_table.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_table/t_table.dart new file mode 100644 index 0000000000..7e6f17efe6 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_table/t_table.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; + +class TTableColumnOption { + const TTableColumnOption({required this.width}); + + final double width; +} + +class TTableRow { + const TTableRow( + {this.decoration, required this.cells, this.onTap, this.onDoubleTap}); + + final BoxDecoration? decoration; + final List cells; + final VoidCallback? onTap; + final VoidCallback? onDoubleTap; +} + +class TTableDataCell { + const TTableDataCell( + {this.alignment = Alignment.centerLeft, required this.widget}); + + final Alignment alignment; + final Widget widget; +} + +class TTable extends StatelessWidget { + const TTable({ + super.key, + required this.header, + required this.rows, + required this.columnOptions, + }); + + final TTableRow header; + final List rows; + final List columnOptions; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final maxWidth = constraints.maxWidth; + if (maxWidth == double.infinity) { + throw StateError('The max width cannot be infinity'); + } + final count = columnOptions.length; + final widths = []; + var remainingWidth = maxWidth; + for (var i = 0; i < count; i++) { + if (i == count - 1) { + widths.add(remainingWidth.truncateToDouble()); + } else { + final width = + (maxWidth * columnOptions[i].width).truncateToDouble(); + widths.add(width); + remainingWidth -= width; + } + } + return Column( + children: [ + _TTableRowView( + row: header, + widths: widths, + columnCount: count, + ), + Expanded( + child: ListView.builder( + addAutomaticKeepAlives: false, + itemCount: rows.length, + itemBuilder: (context, index) => _TTableRowView( + row: rows[index], + widths: widths, + columnCount: count, + ), + ), + ), + ], + ); + }, + ); +} + +class _TTableRowView extends StatefulWidget { + const _TTableRowView( + {required this.row, required this.widths, required this.columnCount}); + + final TTableRow row; + final List widths; + final int columnCount; + + @override + State<_TTableRowView> createState() => _TTableRowViewState(); +} + +class _TTableRowViewState extends State<_TTableRowView> { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final row = widget.row; + final widths = widget.widths; + final onTap = row.onTap; + final onDoubleTap = row.onDoubleTap; + final child = MouseRegion( + cursor: onTap == null && onDoubleTap == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + onEnter: (_) { + _isHovered = true; + setState(() {}); + }, + onExit: (_) { + _isHovered = false; + setState(() {}); + }, + child: GestureDetector( + onTap: () => onTap?.call(), + onDoubleTap: () => onDoubleTap?.call(), + child: Row( + children: List.generate(widget.columnCount, + (index) => _buildCell(row, index, widths[index])), + ), + ), + ); + // TODO + // final decoration = row.decoration; + final decoration = + _isHovered ? BoxDecoration(color: Colors.grey.shade200) : null; + return SizedBox( + height: 48, + child: RepaintBoundary( + child: decoration == null + ? child + : DecoratedBox( + decoration: decoration, + child: child, + ), + ), + ); + } + + Widget _buildCell(TTableRow row, int index, double width) { + final cell = row.cells[index]; + return Align( + alignment: cell.alignment, + child: SizedBox( + width: width, + child: Padding( + // padding: const EdgeInsets.symmetric(horizontal: 10), + padding: EdgeInsets.zero, + child: cell.widget, + ), + ), + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_tabs/t_tabs.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_tabs/t_tabs.dart new file mode 100644 index 0000000000..27f0c65cc3 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_tabs/t_tabs.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/index.dart'; + +class TTabs extends StatefulWidget { + const TTabs( + {super.key, + required this.tabs, + this.selectedTabId, + required this.onTabSelected}); + + final List tabs; + final Object? selectedTabId; + final void Function(int index, TTab tab) onTabSelected; + + @override + State createState() => _TTabsState(); +} + +class _TTabsState extends State { + Object? _hoveringTabId; + + @override + Widget build(BuildContext context) => Column( + children: widget.tabs.indexed.map((item) { + final (index, tab) = item; + final isSelected = widget.selectedTabId == tab.id; + return _buildTab(context.theme, tab, index, isSelected); + }).toList(), + ); + + Widget _buildTab(ThemeData theme, TTab tab, int index, bool isSelected) { + final child = MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hoveringTabId = tab.id), + onExit: (_) => setState(() { + if (_hoveringTabId == tab.id) { + _hoveringTabId = null; + } + }), + child: GestureDetector( + onTap: () { + widget.onTabSelected(index, tab); + setState(() {}); + }, + child: SizedBox( + width: double.maxFinite, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: Sizes.borderRadiusCircular4, + color: isSelected || _hoveringTabId == tab.id + ? const Color.fromARGB(255, 246, 246, 246) + : null, + ), + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 8, horizontal: 24), + child: Text( + tab.text, + style: isSelected + ? TextStyle(color: theme.primaryColor) + : theme.appThemeExtension.tabTextStyle, + ), + ), + )), + )); + if (index == 0) { + return child; + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: child, + ); + } +} + +class TTab { + const TTab({required this.id, required this.text}); + + final Object id; + final String text; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_text/t_paragraph.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_text/t_paragraph.dart new file mode 100644 index 0000000000..dee82bb4b9 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_text/t_paragraph.dart @@ -0,0 +1,3568 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'package:flutter/widgets.dart'; +/// +/// @docImport 'editable.dart'; +import 'dart:collection'; +import 'dart:math' as math; +import 'dart:ui' as ui + show + BoxHeightStyle, + BoxWidthStyle, + Gradient, + LineMetrics, + PlaceholderAlignment, + Shader, + TextBox, + TextHeightBehavior; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +/// The start and end positions for a text boundary. +typedef _TextBoundaryRecord = ({ + TextPosition boundaryStart, + TextPosition boundaryEnd +}); + +/// Signature for a function that determines the [_TextBoundaryRecord] at the given +/// [TextPosition]. +typedef _TextBoundaryAtPosition = _TextBoundaryRecord Function( + TextPosition position); + +/// Signature for a function that determines the [_TextBoundaryRecord] at the given +/// [TextPosition], for the given [String]. +typedef _TextBoundaryAtPositionInText = _TextBoundaryRecord Function( + TextPosition position, String text); + +const String _kEllipsis = '\u2026'; + +/// Used by the [TRenderParagraph] to map its rendering children to their +/// corresponding semantics nodes. +/// +/// The [RichText] uses this to tag the relation between its placeholder spans +/// and their semantics nodes. +@immutable +class PlaceholderSpanIndexSemanticsTag extends SemanticsTag { + /// Creates a semantics tag with the input `index`. + /// + /// Different [PlaceholderSpanIndexSemanticsTag]s with the same `index` are + /// consider the same. + const PlaceholderSpanIndexSemanticsTag(this.index) + : super('PlaceholderSpanIndexSemanticsTag($index)'); + + /// The index of this tag. + final int index; + + @override + bool operator ==(Object other) => + other is PlaceholderSpanIndexSemanticsTag && other.index == index; + + @override + int get hashCode => Object.hash(PlaceholderSpanIndexSemanticsTag, index); +} + +/// Parent data used by [TRenderParagraph] and [RenderEditable] to annotate +/// inline contents (such as [WidgetSpan]s) with. +class TextParentData extends ParentData + with ContainerParentDataMixin { + /// The offset at which to paint the child in the parent's coordinate system. + /// + /// A `null` value indicates this inline widget is not laid out. For instance, + /// when the inline widget has never been laid out, or the inline widget is + /// ellipsized away. + Offset? get offset => _offset; + Offset? _offset; + + /// The [PlaceholderSpan] associated with this render child. + /// + /// This field is usually set by a [ParentDataWidget], and is typically not + /// null when `performLayout` is called. + PlaceholderSpan? span; + + @override + void detach() { + span = null; + _offset = null; + super.detach(); + } + + @override + String toString() => + 'widget: $span, ${offset == null ? "not laid out" : "offset: $offset"}'; +} + +/// A mixin that provides useful default behaviors for text [RenderBox]es +/// ([TRenderParagraph] and [RenderEditable] for example) with inline content +/// children managed by the [ContainerRenderObjectMixin] mixin. +/// +/// This mixin assumes every child managed by the [ContainerRenderObjectMixin] +/// mixin corresponds to a [PlaceholderSpan], and they are organized in logical +/// order of the text (the order each [PlaceholderSpan] is encountered when the +/// user reads the text). +/// +/// To use this mixin in a [RenderBox] class: +/// +/// * Call [layoutInlineChildren] in the `performLayout` and `computeDryLayout` +/// implementation, and during intrinsic size calculations, to get the size +/// information of the inline widgets as a `List` of `PlaceholderDimensions`. +/// Determine the positioning of the inline widgets (which is usually done by +/// a [TextPainter] using its line break algorithm). +/// +/// * Call [positionInlineChildren] with the positioning information of the +/// inline widgets. +/// +/// * Implement [RenderBox.applyPaintTransform], optionally with +/// [defaultApplyPaintTransform]. +/// +/// * Call [paintInlineChildren] in [RenderBox.paint] to paint the inline widgets. +/// +/// * Call [hitTestInlineChildren] in [RenderBox.hitTestChildren] to hit test the +/// inline widgets. +/// +/// See also: +/// +/// * [WidgetSpan.extractFromInlineSpan], a helper function for extracting +/// [WidgetSpan]s from an [InlineSpan] tree. +mixin RenderInlineChildrenContainerDefaults + on RenderBox, ContainerRenderObjectMixin { + @override + void setupParentData(RenderBox child) { + if (child.parentData is! TextParentData) { + child.parentData = TextParentData(); + } + } + + static PlaceholderDimensions _layoutChild( + RenderBox child, + BoxConstraints childConstraints, + ChildLayouter layoutChild, + ChildBaselineGetter getBaseline) { + final parentData = child.parentData! as TextParentData; + final span = parentData.span; + assert(span != null); + return span == null + ? PlaceholderDimensions.empty + : PlaceholderDimensions( + size: layoutChild(child, childConstraints), + alignment: span.alignment, + baseline: span.baseline, + baselineOffset: switch (span.alignment) { + ui.PlaceholderAlignment.aboveBaseline || + ui.PlaceholderAlignment.belowBaseline || + ui.PlaceholderAlignment.bottom || + ui.PlaceholderAlignment.middle || + ui.PlaceholderAlignment.top => + null, + ui.PlaceholderAlignment.baseline => + getBaseline(child, childConstraints, span.baseline!), + }, + ); + } + + /// Computes the layout for every inline child using the `maxWidth` constraint. + /// + /// Returns a list of [PlaceholderDimensions], representing the layout results + /// for each child managed by the [ContainerRenderObjectMixin] mixin. + /// + /// The `getChildBaseline` parameter and the `layoutChild` parameter must be + /// consistent: if `layoutChild` computes the size of the child without + /// modifying the actual layout of that child, then `getChildBaseline` must + /// also be "dry", and vice versa. + /// + /// Since this method does not impose a maximum height constraint on the + /// inline children, some children may become taller than this [RenderBox]. + /// + /// See also: + /// + /// * [TextPainter.setPlaceholderDimensions], the method that usually takes + /// the layout results from this method as the input. + @protected + List layoutInlineChildren(double maxWidth, + ChildLayouter layoutChild, ChildBaselineGetter getChildBaseline) { + final constraints = BoxConstraints(maxWidth: maxWidth); + return [ + for (RenderBox? child = firstChild; + child != null; + child = childAfter(child)) + _layoutChild(child, constraints, layoutChild, getChildBaseline), + ]; + } + + /// Positions each inline child according to the coordinates provided in the + /// `boxes` list. + /// + /// The `boxes` list must be in logical order, which is the order each child + /// is encountered when the user reads the text. Usually the length of the + /// list equals [childCount], but it can be less than that, when some children + /// are omitted due to ellipsing. It never exceeds [childCount]. + /// + /// See also: + /// + /// * [TextPainter.inlinePlaceholderBoxes], the method that can be used to + /// get the input `boxes`. + @protected + void positionInlineChildren(List boxes) { + var child = firstChild; + for (final box in boxes) { + if (child == null) { + assert(false, + 'The length of boxes (${boxes.length}) should be greater than childCount ($childCount)'); + return; + } + final textParentData = child.parentData! as TextParentData; + textParentData._offset = Offset(box.left, box.top); + child = childAfter(child); + } + while (child != null) { + final textParentData = child.parentData! as TextParentData; + textParentData._offset = null; + child = childAfter(child); + } + } + + /// Applies the transform that would be applied when painting the given child + /// to the given matrix. + /// + /// Render children whose [TextParentData.offset] is null zeros out the + /// `transform` to indicate they're invisible thus should not be painted. + @protected + void defaultApplyPaintTransform(RenderBox child, Matrix4 transform) { + final childParentData = child.parentData! as TextParentData; + final offset = childParentData.offset; + if (offset == null) { + transform.setZero(); + } else { + transform.translate(offset.dx, offset.dy); + } + } + + /// Paints each inline child. + /// + /// Render children whose [TextParentData.offset] is null will be skipped by + /// this method. + @protected + void paintInlineChildren(PaintingContext context, Offset offset) { + var child = firstChild; + while (child != null) { + final childParentData = child.parentData! as TextParentData; + final childOffset = childParentData.offset; + if (childOffset == null) { + return; + } + context.paintChild(child, childOffset + offset); + child = childAfter(child); + } + } + + /// Performs a hit test on each inline child. + /// + /// Render children whose [TextParentData.offset] is null will be skipped by + /// this method. + @protected + bool hitTestInlineChildren(BoxHitTestResult result, Offset position) { + var child = firstChild; + while (child != null) { + final childParentData = child.parentData! as TextParentData; + final childOffset = childParentData.offset; + if (childOffset == null) { + return false; + } + final isHit = result.addWithPaintOffset( + offset: childOffset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) => + child!.hitTest(result, position: transformed), + ); + if (isHit) { + return true; + } + child = childAfter(child); + } + return false; + } +} + +/// A render object that displays a paragraph of text. +class TRenderParagraph extends RenderBox + with + ContainerRenderObjectMixin, + RenderInlineChildrenContainerDefaults, + RelayoutWhenSystemFontsChangeMixin { + /// Creates a paragraph render object. + /// + /// The [maxLines] property may be null (and indeed defaults to null), but if + /// it is not null, it must be greater than zero. + TRenderParagraph( + InlineSpan text, { + TextAlign textAlign = TextAlign.start, + required TextDirection textDirection, + bool softWrap = true, + TextOverflow overflow = TextOverflow.clip, + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + double textScaleFactor = 1.0, + TextScaler textScaler = TextScaler.noScaling, + int? maxLines, + Locale? locale, + StrutStyle? strutStyle, + TextWidthBasis textWidthBasis = TextWidthBasis.parent, + ui.TextHeightBehavior? textHeightBehavior, + List? children, + Color? selectionColor, + SelectionRegistrar? registrar, + }) : assert(text.debugAssertIsValid()), + assert(maxLines == null || maxLines > 0), + assert( + identical(textScaler, TextScaler.noScaling) || textScaleFactor == 1.0, + 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', + ), + _softWrap = softWrap, + _overflow = overflow, + _selectionColor = selectionColor, + _textPainter = TextPainter( + text: text, + textAlign: textAlign, + textDirection: textDirection, + textScaler: textScaler == TextScaler.noScaling + ? TextScaler.linear(textScaleFactor) + : textScaler, + maxLines: maxLines, + ellipsis: overflow == TextOverflow.ellipsis ? _kEllipsis : null, + locale: locale, + strutStyle: strutStyle, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + ) { + addAll(children); + this.registrar = registrar; + } + + static final String _placeholderCharacter = + String.fromCharCode(PlaceholderSpan.placeholderCodeUnit); + + final TextPainter _textPainter; + + // Currently, computing min/max intrinsic width/height will destroy state + // inside the painter. Instead of calling _layout again to get back the correct + // state, use a separate TextPainter for intrinsics calculation. + // + // TODO(abarth): Make computing the min/max intrinsic width/height a + // non-destructive operation. + TextPainter? _textIntrinsicsCache; + + TextPainter get _textIntrinsics => (_textIntrinsicsCache ??= TextPainter()) + ..text = _textPainter.text + ..textAlign = _textPainter.textAlign + ..textDirection = _textPainter.textDirection + ..textScaler = _textPainter.textScaler + ..maxLines = _textPainter.maxLines + ..ellipsis = _textPainter.ellipsis + ..locale = _textPainter.locale + ..strutStyle = _textPainter.strutStyle + ..textWidthBasis = _textPainter.textWidthBasis + ..textHeightBehavior = _textPainter.textHeightBehavior; + + List? _cachedAttributedLabels; + + List? _cachedCombinedSemanticsInfos; + + /// The text to display. + InlineSpan get text => _textPainter.text!; + + set text(InlineSpan value) { + switch (_textPainter.text!.compareTo(value)) { + case RenderComparison.identical: + return; + case RenderComparison.metadata: + _textPainter.text = value; + _cachedCombinedSemanticsInfos = null; + markNeedsSemanticsUpdate(); + case RenderComparison.paint: + _textPainter.text = value; + _cachedAttributedLabels = null; + _cachedCombinedSemanticsInfos = null; + markNeedsPaint(); + markNeedsSemanticsUpdate(); + case RenderComparison.layout: + _textPainter.text = value; + _overflowShader = null; + _cachedAttributedLabels = null; + _cachedCombinedSemanticsInfos = null; + markNeedsLayout(); + _removeSelectionRegistrarSubscription(); + _disposeSelectableFragments(); + _updateSelectionRegistrarSubscription(); + } + } + + /// The ongoing selections in this paragraph. + /// + /// The selection does not include selections in [PlaceholderSpan] if there + /// are any. + @visibleForTesting + List get selections { + if (_lastSelectableFragments == null) { + return const []; + } + final results = []; + for (final fragment in _lastSelectableFragments!) { + if (fragment._textSelectionStart != null && + fragment._textSelectionEnd != null) { + results.add(TextSelection( + baseOffset: fragment._textSelectionStart!.offset, + extentOffset: fragment._textSelectionEnd!.offset)); + } + } + return results; + } + + // Should be null if selection is not enabled, i.e. _registrar = null. The + // paragraph splits on [PlaceholderSpan.placeholderCodeUnit], and stores each + // fragment in this list. + List<_SelectableFragment>? _lastSelectableFragments; + + /// The [SelectionRegistrar] this paragraph will be, or is, registered to. + SelectionRegistrar? get registrar => _registrar; + SelectionRegistrar? _registrar; + + set registrar(SelectionRegistrar? value) { + if (value == _registrar) { + return; + } + _removeSelectionRegistrarSubscription(); + _disposeSelectableFragments(); + _registrar = value; + _updateSelectionRegistrarSubscription(); + } + + void _updateSelectionRegistrarSubscription() { + if (_registrar == null) { + return; + } + _lastSelectableFragments ??= _getSelectableFragments(); + _lastSelectableFragments!.forEach(_registrar!.add); + if (_lastSelectableFragments!.isNotEmpty) { + markNeedsCompositingBitsUpdate(); + } + } + + void _removeSelectionRegistrarSubscription() { + if (_registrar == null || _lastSelectableFragments == null) { + return; + } + _lastSelectableFragments!.forEach(_registrar!.remove); + } + + List<_SelectableFragment> _getSelectableFragments() { + final plainText = text.toPlainText(includeSemanticsLabels: false); + final result = <_SelectableFragment>[]; + var start = 0; + while (start < plainText.length) { + var end = plainText.indexOf(_placeholderCharacter, start); + if (start != end) { + if (end == -1) { + end = plainText.length; + } + result.add( + _SelectableFragment( + paragraph: this, + range: TextRange(start: start, end: end), + fullText: plainText, + ), + ); + start = end; + } + start += 1; + } + return result; + } + + /// Determines whether the given [Selectable] was created by this + /// [TRenderParagraph]. + bool selectableBelongsToParagraph(Selectable selectable) { + if (_lastSelectableFragments == null) { + return false; + } + return _lastSelectableFragments!.contains(selectable); + } + + void _disposeSelectableFragments() { + if (_lastSelectableFragments == null) { + return; + } + for (final fragment in _lastSelectableFragments!) { + fragment.dispose(); + } + _lastSelectableFragments = null; + } + + @override + bool get alwaysNeedsCompositing => + _lastSelectableFragments?.isNotEmpty ?? false; + + @override + void markNeedsLayout() { + _lastSelectableFragments?.forEach( + (_SelectableFragment element) => element.didChangeParagraphLayout()); + super.markNeedsLayout(); + } + + @override + void dispose() { + _removeSelectionRegistrarSubscription(); + _disposeSelectableFragments(); + _textPainter.dispose(); + _textIntrinsicsCache?.dispose(); + super.dispose(); + } + + /// How the text should be aligned horizontally. + TextAlign get textAlign => _textPainter.textAlign; + + set textAlign(TextAlign value) { + if (_textPainter.textAlign == value) { + return; + } + _textPainter.textAlign = value; + markNeedsPaint(); + } + + /// The directionality of the text. + /// + /// This decides how the [TextAlign.start], [TextAlign.end], and + /// [TextAlign.justify] values of [textAlign] are interpreted. + /// + /// This is also used to disambiguate how to render bidirectional text. For + /// example, if the [text] is an English phrase followed by a Hebrew phrase, + /// in a [TextDirection.ltr] context the English phrase will be on the left + /// and the Hebrew phrase to its right, while in a [TextDirection.rtl] + /// context, the English phrase will be on the right and the Hebrew phrase on + /// its left. + TextDirection get textDirection => _textPainter.textDirection!; + + set textDirection(TextDirection value) { + if (_textPainter.textDirection == value) { + return; + } + _textPainter.textDirection = value; + markNeedsLayout(); + } + + /// Whether the text should break at soft line breaks. + /// + /// If false, the glyphs in the text will be positioned as if there was + /// unlimited horizontal space. + /// + /// If [softWrap] is false, [overflow] and [textAlign] may have unexpected + /// effects. + bool get softWrap => _softWrap; + bool _softWrap; + + set softWrap(bool value) { + if (_softWrap == value) { + return; + } + _softWrap = value; + markNeedsLayout(); + } + + /// How visual overflow should be handled. + TextOverflow get overflow => _overflow; + TextOverflow _overflow; + + set overflow(TextOverflow value) { + if (_overflow == value) { + return; + } + _overflow = value; + _textPainter.ellipsis = value == TextOverflow.ellipsis ? _kEllipsis : null; + markNeedsLayout(); + } + + /// Deprecated. Will be removed in a future version of Flutter. Use + /// [textScaler] instead. + /// + /// The number of font pixels for each logical pixel. + /// + /// For example, if the text scale factor is 1.5, text will be 50% larger than + /// the specified font size. + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + double get textScaleFactor => _textPainter.textScaleFactor; + + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + set textScaleFactor(double value) { + textScaler = TextScaler.linear(value); + } + + /// {@macro flutter.painting.textPainter.textScaler} + TextScaler get textScaler => _textPainter.textScaler; + + set textScaler(TextScaler value) { + if (_textPainter.textScaler == value) { + return; + } + _textPainter.textScaler = value; + _overflowShader = null; + markNeedsLayout(); + } + + /// An optional maximum number of lines for the text to span, wrapping if + /// necessary. If the text exceeds the given number of lines, it will be + /// truncated according to [overflow] and [softWrap]. + int? get maxLines => _textPainter.maxLines; + + /// The value may be null. If it is not null, then it must be greater than + /// zero. + set maxLines(int? value) { + assert(value == null || value > 0); + if (_textPainter.maxLines == value) { + return; + } + _textPainter.maxLines = value; + _overflowShader = null; + markNeedsLayout(); + } + + /// Used by this paragraph's internal [TextPainter] to select a + /// locale-specific font. + /// + /// In some cases, the same Unicode character may be rendered differently + /// depending on the locale. For example, the '骨' character is rendered + /// differently in the Chinese and Japanese locales. In these cases, the + /// [locale] may be used to select a locale-specific font. + Locale? get locale => _textPainter.locale; + + /// The value may be null. + set locale(Locale? value) { + if (_textPainter.locale == value) { + return; + } + _textPainter.locale = value; + _overflowShader = null; + markNeedsLayout(); + } + + /// {@macro flutter.painting.textPainter.strutStyle} + StrutStyle? get strutStyle => _textPainter.strutStyle; + + /// The value may be null. + set strutStyle(StrutStyle? value) { + if (_textPainter.strutStyle == value) { + return; + } + _textPainter.strutStyle = value; + _overflowShader = null; + markNeedsLayout(); + } + + /// {@macro flutter.painting.textPainter.textWidthBasis} + TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis; + + set textWidthBasis(TextWidthBasis value) { + if (_textPainter.textWidthBasis == value) { + return; + } + _textPainter.textWidthBasis = value; + _overflowShader = null; + markNeedsLayout(); + } + + /// {@macro dart.ui.textHeightBehavior} + ui.TextHeightBehavior? get textHeightBehavior => + _textPainter.textHeightBehavior; + + set textHeightBehavior(ui.TextHeightBehavior? value) { + if (_textPainter.textHeightBehavior == value) { + return; + } + _textPainter.textHeightBehavior = value; + _overflowShader = null; + markNeedsLayout(); + } + + /// The color to use when painting the selection. + /// + /// Ignored if the text is not selectable (e.g. if [registrar] is null). + Color? get selectionColor => _selectionColor; + Color? _selectionColor; + + set selectionColor(Color? value) { + if (_selectionColor == value) { + return; + } + _selectionColor = value; + if (_lastSelectableFragments?.any( + (_SelectableFragment fragment) => fragment.value.hasSelection) ?? + false) { + markNeedsPaint(); + } + } + + Offset _getOffsetForPosition(TextPosition position) => + getOffsetForCaret(position, Rect.zero) + + Offset(0, getFullHeightForCaret(position)); + + @override + double computeMinIntrinsicWidth(double height) { + final placeholderDimensions = layoutInlineChildren( + double.infinity, + (RenderBox child, BoxConstraints constraints) => + Size(child.getMinIntrinsicWidth(double.infinity), 0.0), + ChildLayoutHelper.getDryBaseline, + ); + return (_textIntrinsics + ..setPlaceholderDimensions(placeholderDimensions) + ..layout()) + .minIntrinsicWidth; + } + + @override + double computeMaxIntrinsicWidth(double height) { + final placeholderDimensions = layoutInlineChildren( + double.infinity, + // Height and baseline is irrelevant as all text will be laid + // out in a single line. Therefore, using 0.0 as a dummy for the height. + (RenderBox child, BoxConstraints constraints) => + Size(child.getMaxIntrinsicWidth(double.infinity), 0.0), + ChildLayoutHelper.getDryBaseline, + ); + return (_textIntrinsics + ..setPlaceholderDimensions(placeholderDimensions) + ..layout()) + .maxIntrinsicWidth; + } + + /// An estimate of the height of a line in the text. See [TextPainter.preferredLineHeight]. + /// + /// This does not require the layout to be updated. + @visibleForTesting + double get preferredLineHeight => _textPainter.preferredLineHeight; + + double _computeIntrinsicHeight(double width) => (_textIntrinsics + ..setPlaceholderDimensions(layoutInlineChildren(width, + ChildLayoutHelper.dryLayoutChild, ChildLayoutHelper.getDryBaseline)) + ..layout(minWidth: width, maxWidth: _adjustMaxWidth(width))) + .height; + + @override + double computeMinIntrinsicHeight(double width) => + _computeIntrinsicHeight(width); + + @override + double computeMaxIntrinsicHeight(double width) => + _computeIntrinsicHeight(width); + + @override + bool hitTestSelf(Offset position) => true; + + @override + @protected + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + final glyph = _textPainter.getClosestGlyphForOffset(position); + // The hit-test can't fall through the horizontal gaps between visually + // adjacent characters on the same line, even with a large letter-spacing or + // text justification, as graphemeClusterLayoutBounds.width is the advance + // width to the next character, so there's no gap between their + // graphemeClusterLayoutBounds rects. + final spanHit = + glyph != null && glyph.graphemeClusterLayoutBounds.contains(position) + ? _textPainter.text!.getSpanForPosition( + TextPosition(offset: glyph.graphemeClusterCodeUnitRange.start)) + : null; + switch (spanHit) { + case final HitTestTarget span: + result.add(HitTestEntry(span)); + return true; + case _: + return hitTestInlineChildren(result, position); + } + } + + bool _needsClipping = false; + ui.Shader? _overflowShader; + + /// Whether this paragraph currently has a [dart:ui.Shader] for its overflow + /// effect. + /// + /// Used to test this object. Not for use in production. + @visibleForTesting + bool get debugHasOverflowShader => _overflowShader != null; + + @override + void systemFontsDidChange() { + super.systemFontsDidChange(); + _textPainter.markNeedsLayout(); + } + + // Placeholder dimensions representing the sizes of child inline widgets. + // + // These need to be cached because the text painter's placeholder dimensions + // will be overwritten during intrinsic width/height calculations and must be + // restored to the original values before final layout and painting. + List? _placeholderDimensions; + + double _adjustMaxWidth(double maxWidth) => + softWrap || overflow == TextOverflow.ellipsis + ? maxWidth + : double.infinity; + + void _layoutTextWithConstraints(BoxConstraints constraints) { + _textPainter + ..setPlaceholderDimensions(_placeholderDimensions) + ..layout( + minWidth: constraints.minWidth, + maxWidth: _adjustMaxWidth(constraints.maxWidth)); + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + final size = (_textIntrinsics + ..setPlaceholderDimensions(layoutInlineChildren( + constraints.maxWidth, + ChildLayoutHelper.dryLayoutChild, + ChildLayoutHelper.getDryBaseline)) + ..layout( + minWidth: constraints.minWidth, + maxWidth: _adjustMaxWidth(constraints.maxWidth))) + .size; + return constraints.constrain(size); + } + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) { + assert(!debugNeedsLayout); + assert(constraints.debugAssertIsValid()); + _layoutTextWithConstraints(constraints); + // TODO(garyq): Since our metric for ideographic baseline is currently + // inaccurate and the non-alphabetic baselines are based off of the + // alphabetic baseline, we use the alphabetic for now to produce correct + // layouts. We should eventually change this back to pass the `baseline` + // property when the ideographic baseline is properly implemented + // (https://github.com/flutter/flutter/issues/22625). + return _textPainter + .computeDistanceToActualBaseline(TextBaseline.alphabetic); + } + + @override + double computeDryBaseline( + covariant BoxConstraints constraints, TextBaseline baseline) { + assert(constraints.debugAssertIsValid()); + _textIntrinsics + ..setPlaceholderDimensions(layoutInlineChildren(constraints.maxWidth, + ChildLayoutHelper.dryLayoutChild, ChildLayoutHelper.getDryBaseline)) + ..layout( + minWidth: constraints.minWidth, + maxWidth: _adjustMaxWidth(constraints.maxWidth)); + return _textIntrinsics + .computeDistanceToActualBaseline(TextBaseline.alphabetic); + } + + @override + void performLayout() { + _lastSelectableFragments?.forEach( + (_SelectableFragment element) => element.didChangeParagraphLayout()); + final constraints = this.constraints; + _placeholderDimensions = layoutInlineChildren(constraints.maxWidth, + ChildLayoutHelper.layoutChild, ChildLayoutHelper.getBaseline); + _layoutTextWithConstraints(constraints); + positionInlineChildren(_textPainter.inlinePlaceholderBoxes!); + + final textSize = _textPainter.size; + size = constraints.constrain(textSize); + + final didOverflowHeight = + size.height < textSize.height || _textPainter.didExceedMaxLines; + final didOverflowWidth = size.width < textSize.width; + // TODO(abarth): We're only measuring the sizes of the line boxes here. If + // the glyphs draw outside the line boxes, we might think that there isn't + // visual overflow when there actually is visual overflow. This can become + // a problem if we start having horizontal overflow and introduce a clip + // that affects the actual (but undetected) vertical overflow. + final hasVisualOverflow = didOverflowWidth || didOverflowHeight; + if (hasVisualOverflow) { + switch (_overflow) { + case TextOverflow.visible: + _needsClipping = false; + _overflowShader = null; + case TextOverflow.clip: + case TextOverflow.ellipsis: + _needsClipping = true; + _overflowShader = null; + case TextOverflow.fade: + _needsClipping = true; + final fadeSizePainter = TextPainter( + text: TextSpan(style: _textPainter.text!.style, text: '\u2026'), + textDirection: textDirection, + textScaler: textScaler, + locale: locale, + )..layout(); + if (didOverflowWidth) { + final (double fadeStart, double fadeEnd) = switch (textDirection) { + TextDirection.rtl => (fadeSizePainter.width, 0.0), + TextDirection.ltr => ( + size.width - fadeSizePainter.width, + size.width + ), + }; + _overflowShader = ui.Gradient.linear( + Offset(fadeStart, 0.0), + Offset(fadeEnd, 0.0), + [const Color(0xFFFFFFFF), const Color(0x00FFFFFF)], + ); + } else { + final fadeEnd = size.height; + final fadeStart = fadeEnd - fadeSizePainter.height / 2.0; + _overflowShader = ui.Gradient.linear( + Offset(0.0, fadeStart), + Offset(0.0, fadeEnd), + [const Color(0xFFFFFFFF), const Color(0x00FFFFFF)], + ); + } + fadeSizePainter.dispose(); + } + } else { + _needsClipping = false; + _overflowShader = null; + } + } + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + defaultApplyPaintTransform(child, transform); + } + + @override + void paint(PaintingContext context, Offset offset) { + // Text alignment only triggers repaint so it's possible the text layout has + // been invalidated but performLayout wasn't called at this point. Make sure + // the TextPainter has a valid layout. + _layoutTextWithConstraints(constraints); + assert(() { + if (debugRepaintTextRainbowEnabled) { + final paint = Paint()..color = debugCurrentRepaintColor.toColor(); + context.canvas.drawRect(offset & size, paint); + } + return true; + }()); + + if (_needsClipping) { + final bounds = offset & size; + if (_overflowShader != null) { + // This layer limits what the shader below blends with to be just the + // text (as opposed to the text and its background). + context.canvas.saveLayer(bounds, Paint()); + } else { + context.canvas.save(); + } + context.canvas.clipRect(bounds); + } + + if (_lastSelectableFragments != null) { + for (final fragment in _lastSelectableFragments!) { + fragment.paint(context, offset); + } + } + + _textPainter.paint(context.canvas, offset); + + paintInlineChildren(context, offset); + + if (_needsClipping) { + if (_overflowShader != null) { + context.canvas.translate(offset.dx, offset.dy); + final paint = Paint() + ..blendMode = BlendMode.modulate + ..shader = _overflowShader; + context.canvas.drawRect(Offset.zero & size, paint); + } + context.canvas.restore(); + } + } + + /// Returns the offset at which to paint the caret. + /// + /// Valid only after [layout]. + Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { + assert(!debugNeedsLayout); + _layoutTextWithConstraints(constraints); + return _textPainter.getOffsetForCaret(position, caretPrototype); + } + + /// {@macro flutter.painting.textPainter.getFullHeightForCaret} + /// + /// Valid only after [layout]. + double getFullHeightForCaret(TextPosition position) { + assert(!debugNeedsLayout); + _layoutTextWithConstraints(constraints); + return _textPainter.getFullHeightForCaret(position, Rect.zero); + } + + /// Returns a list of rects that bound the given selection. + /// + /// The [boxHeightStyle] and [boxWidthStyle] arguments may be used to select + /// the shape of the [TextBox]es. These properties default to + /// [ui.BoxHeightStyle.tight] and [ui.BoxWidthStyle.tight] respectively. + /// + /// A given selection might have more than one rect if the [TRenderParagraph] + /// contains multiple [InlineSpan]s or bidirectional text, because logically + /// contiguous text might not be visually contiguous. + /// + /// Valid only after [layout]. + /// + /// See also: + /// + /// * [TextPainter.getBoxesForSelection], the method in TextPainter to get + /// the equivalent boxes. + List getBoxesForSelection( + TextSelection selection, { + ui.BoxHeightStyle boxHeightStyle = ui.BoxHeightStyle.max, + ui.BoxWidthStyle boxWidthStyle = ui.BoxWidthStyle.tight, + }) { + assert(!debugNeedsLayout); + _layoutTextWithConstraints(constraints); + return _textPainter.getBoxesForSelection( + selection, + boxHeightStyle: boxHeightStyle, + boxWidthStyle: boxWidthStyle, + ); + } + + /// Returns the position within the text for the given pixel offset. + /// + /// Valid only after [layout]. + TextPosition getPositionForOffset(Offset offset) { + assert(!debugNeedsLayout); + _layoutTextWithConstraints(constraints); + return _textPainter.getPositionForOffset(offset); + } + + /// Returns the text range of the word at the given offset. Characters not + /// part of a word, such as spaces, symbols, and punctuation, have word breaks + /// on both sides. In such cases, this method will return a text range that + /// contains the given text position. + /// + /// Word boundaries are defined more precisely in Unicode Standard Annex #29 + /// . + /// + /// Valid only after [layout]. + TextRange getWordBoundary(TextPosition position) { + assert(!debugNeedsLayout); + _layoutTextWithConstraints(constraints); + return _textPainter.getWordBoundary(position); + } + + TextRange _getLineAtOffset(TextPosition position) => + _textPainter.getLineBoundary(position); + + TextPosition _getTextPositionAbove(TextPosition position) { + // -0.5 of preferredLineHeight points to the middle of the line above. + final preferredLineHeight = _textPainter.preferredLineHeight; + final verticalOffset = -0.5 * preferredLineHeight; + return _getTextPositionVertical(position, verticalOffset); + } + + TextPosition _getTextPositionBelow(TextPosition position) { + // 1.5 of preferredLineHeight points to the middle of the line below. + final preferredLineHeight = _textPainter.preferredLineHeight; + final verticalOffset = 1.5 * preferredLineHeight; + return _getTextPositionVertical(position, verticalOffset); + } + + TextPosition _getTextPositionVertical( + TextPosition position, double verticalOffset) { + final caretOffset = _textPainter.getOffsetForCaret(position, Rect.zero); + final caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset); + return _textPainter.getPositionForOffset(caretOffsetTranslated); + } + + /// Returns the size of the text as laid out. + /// + /// This can differ from [size] if the text overflowed or if the [constraints] + /// provided by the parent [RenderObject] forced the layout to be bigger than + /// necessary for the given [text]. + /// + /// This returns the [TextPainter.size] of the underlying [TextPainter]. + /// + /// Valid only after [layout]. + Size get textSize { + assert(!debugNeedsLayout); + return _textPainter.size; + } + + /// Whether the text was truncated or ellipsized as laid out. + /// + /// This returns the [TextPainter.didExceedMaxLines] of the underlying [TextPainter]. + /// + /// Valid only after [layout]. + bool get didExceedMaxLines { + assert(!debugNeedsLayout); + return _textPainter.didExceedMaxLines; + } + + /// Collected during [describeSemanticsConfiguration], used by + /// [assembleSemanticsNode]. + List? _semanticsInfo; + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + _semanticsInfo = text.getSemanticsInformation(); + var needsAssembleSemanticsNode = false; + var needsChildConfigurationsDelegate = false; + for (final info in _semanticsInfo!) { + if (info.recognizer != null) { + needsAssembleSemanticsNode = true; + break; + } + needsChildConfigurationsDelegate = + needsChildConfigurationsDelegate || info.isPlaceholder; + } + + if (needsAssembleSemanticsNode) { + config.explicitChildNodes = true; + config.isSemanticBoundary = true; + } else if (needsChildConfigurationsDelegate) { + config.childConfigurationsDelegate = + _childSemanticsConfigurationsDelegate; + } else { + if (_cachedAttributedLabels == null) { + final buffer = StringBuffer(); + var offset = 0; + final attributes = []; + for (final info in _semanticsInfo!) { + final label = info.semanticsLabel ?? info.text; + for (final infoAttribute in info.stringAttributes) { + final originalRange = infoAttribute.range; + attributes.add( + infoAttribute.copy( + range: TextRange( + start: offset + originalRange.start, + end: offset + originalRange.end, + ), + ), + ); + } + buffer.write(label); + offset += label.length; + } + _cachedAttributedLabels = [ + AttributedString(buffer.toString(), attributes: attributes) + ]; + } + config.attributedLabel = _cachedAttributedLabels![0]; + config.textDirection = textDirection; + } + } + + ChildSemanticsConfigurationsResult _childSemanticsConfigurationsDelegate( + List childConfigs) { + final builder = ChildSemanticsConfigurationsResultBuilder(); + var placeholderIndex = 0; + var childConfigsIndex = 0; + var attributedLabelCacheIndex = 0; + InlineSpanSemanticsInformation? seenTextInfo; + _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!); + for (final info in _cachedCombinedSemanticsInfos!) { + if (info.isPlaceholder) { + if (seenTextInfo != null) { + builder.markAsMergeUp(_createSemanticsConfigForTextInfo( + seenTextInfo, attributedLabelCacheIndex)); + attributedLabelCacheIndex += 1; + } + // Mark every childConfig belongs to this placeholder to merge up group. + while (childConfigsIndex < childConfigs.length && + childConfigs[childConfigsIndex].tagsChildrenWith( + PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { + builder.markAsMergeUp(childConfigs[childConfigsIndex]); + childConfigsIndex += 1; + } + placeholderIndex += 1; + } else { + seenTextInfo = info; + } + } + + // Handle plain text info at the end. + if (seenTextInfo != null) { + builder.markAsMergeUp(_createSemanticsConfigForTextInfo( + seenTextInfo, attributedLabelCacheIndex)); + } + return builder.build(); + } + + SemanticsConfiguration _createSemanticsConfigForTextInfo( + InlineSpanSemanticsInformation textInfo, int cacheIndex) { + assert(!textInfo.requiresOwnNode); + final cachedStrings = _cachedAttributedLabels ??= []; + assert(cacheIndex <= cachedStrings.length); + final hasCache = cacheIndex < cachedStrings.length; + + late AttributedString attributedLabel; + if (hasCache) { + attributedLabel = cachedStrings[cacheIndex]; + } else { + assert(cachedStrings.length == cacheIndex); + attributedLabel = AttributedString( + textInfo.semanticsLabel ?? textInfo.text, + attributes: textInfo.stringAttributes, + ); + cachedStrings.add(attributedLabel); + } + return SemanticsConfiguration() + ..textDirection = textDirection + ..attributedLabel = attributedLabel; + } + + // Caches [SemanticsNode]s created during [assembleSemanticsNode] so they + // can be re-used when [assembleSemanticsNode] is called again. This ensures + // stable ids for the [SemanticsNode]s of [TextSpan]s across + // [assembleSemanticsNode] invocations. + LinkedHashMap? _cachedChildNodes; + + @override + void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, + Iterable children) { + assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty); + final newChildren = []; + var currentDirection = textDirection; + Rect currentRect; + var ordinal = 0.0; + var start = 0; + var placeholderIndex = 0; + var childIndex = 0; + var child = firstChild; + final newChildCache = LinkedHashMap(); + _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!); + for (final info in _cachedCombinedSemanticsInfos!) { + final selection = TextSelection( + baseOffset: start, + extentOffset: start + info.text.length, + ); + start += info.text.length; + + if (info.isPlaceholder) { + // A placeholder span may have 0 to multiple semantics nodes, we need + // to annotate all of the semantics nodes belong to this span. + while (children.length > childIndex && + children + .elementAt(childIndex) + .isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { + final childNode = children.elementAt(childIndex); + final parentData = child!.parentData! as TextParentData; + // parentData.scale may be null if the render object is truncated. + if (parentData.offset != null) { + newChildren.add(childNode); + } + childIndex += 1; + } + child = childAfter(child!); + placeholderIndex += 1; + } else { + final initialDirection = currentDirection; + final rects = getBoxesForSelection(selection); + if (rects.isEmpty) { + continue; + } + var rect = rects.first.toRect(); + currentDirection = rects.first.direction; + for (final textBox in rects.skip(1)) { + rect = rect.expandToInclude(textBox.toRect()); + currentDirection = textBox.direction; + } + // Any of the text boxes may have had infinite dimensions. + // We shouldn't pass infinite dimensions up to the bridges. + rect = Rect.fromLTWH( + math.max(0.0, rect.left), + math.max(0.0, rect.top), + math.min(rect.width, constraints.maxWidth), + math.min(rect.height, constraints.maxHeight), + ); + // round the current rectangle to make this API testable and add some + // padding so that the accessibility rects do not overlap with the text. + currentRect = Rect.fromLTRB( + rect.left.floorToDouble() - 4.0, + rect.top.floorToDouble() - 4.0, + rect.right.ceilToDouble() + 4.0, + rect.bottom.ceilToDouble() + 4.0, + ); + final configuration = SemanticsConfiguration() + ..sortKey = OrdinalSortKey(ordinal++) + ..textDirection = initialDirection + ..attributedLabel = AttributedString(info.semanticsLabel ?? info.text, + attributes: info.stringAttributes); + switch (info.recognizer) { + case TapGestureRecognizer(onTap: final VoidCallback? handler): + case DoubleTapGestureRecognizer( + onDoubleTap: final VoidCallback? handler + ): + if (handler != null) { + configuration.onTap = handler; + configuration.isLink = true; + } + case LongPressGestureRecognizer( + onLongPress: final GestureLongPressCallback? onLongPress + ): + if (onLongPress != null) { + configuration.onLongPress = onLongPress; + } + case null: + break; + default: + assert(false, '${info.recognizer.runtimeType} is not supported.'); + } + if (node.parentPaintClipRect != null) { + final paintRect = node.parentPaintClipRect!.intersect(currentRect); + configuration.isHidden = paintRect.isEmpty && !currentRect.isEmpty; + } + final SemanticsNode newChild; + if (_cachedChildNodes?.isNotEmpty ?? false) { + newChild = _cachedChildNodes!.remove(_cachedChildNodes!.keys.first)!; + } else { + final key = UniqueKey(); + newChild = SemanticsNode( + key: key, + showOnScreen: _createShowOnScreenFor(key), + ); + } + newChild + ..updateWith(config: configuration) + ..rect = currentRect; + newChildCache[newChild.key!] = newChild; + newChildren.add(newChild); + } + } + // Makes sure we annotated all of the semantics children. + assert(childIndex == children.length); + assert(child == null); + + _cachedChildNodes = newChildCache; + node.updateWith(config: config, childrenInInversePaintOrder: newChildren); + } + + VoidCallback? _createShowOnScreenFor(Key key) => () { + final node = _cachedChildNodes![key]!; + showOnScreen(descendant: this, rect: node.rect); + }; + + @override + void clearSemantics() { + super.clearSemantics(); + _cachedChildNodes = null; + } + + @override + List debugDescribeChildren() => [ + text.toDiagnosticsNode( + name: 'text', + style: DiagnosticsTreeStyle.transition, + ), + ]; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(EnumProperty('textAlign', textAlign)); + properties.add(EnumProperty('textDirection', textDirection)); + properties.add( + FlagProperty( + 'softWrap', + value: softWrap, + ifTrue: 'wrapping at box width', + ifFalse: 'no wrapping except at line break characters', + showName: true, + ), + ); + properties.add(EnumProperty('overflow', overflow)); + properties.add( + DiagnosticsProperty('textScaler', textScaler, + defaultValue: TextScaler.noScaling), + ); + properties.add( + DiagnosticsProperty( + 'locale', + locale, + defaultValue: null, + ), + ); + properties.add(IntProperty('maxLines', maxLines, ifNull: 'unlimited')); + } +} + +/// A continuous, selectable piece of paragraph. +/// +/// Since the selections in [PlaceholderSpan] are handled independently in its +/// subtree, a selection in [TRenderParagraph] can't continue across a +/// [PlaceholderSpan]. The [TRenderParagraph] splits itself on [PlaceholderSpan] +/// to create multiple `_SelectableFragment`s so that they can be selected +/// separately. +class _SelectableFragment + with Selectable, Diagnosticable, ChangeNotifier + implements TextLayoutMetrics { + _SelectableFragment({ + required this.paragraph, + required this.fullText, + required this.range, + }) : assert(range.isValid && !range.isCollapsed && range.isNormalized) { + if (kFlutterMemoryAllocationsEnabled) { + ChangeNotifier.maybeDispatchObjectCreation(this); + } + _selectionGeometry = _getSelectionGeometry(); + } + + final TextRange range; + final TRenderParagraph paragraph; + final String fullText; + + TextPosition? _textSelectionStart; + TextPosition? _textSelectionEnd; + + bool _selectableContainsOriginTextBoundary = false; + + LayerLink? _startHandleLayerLink; + LayerLink? _endHandleLayerLink; + + @override + SelectionGeometry get value => _selectionGeometry; + late SelectionGeometry _selectionGeometry; + + void _updateSelectionGeometry() { + final newValue = _getSelectionGeometry(); + + if (_selectionGeometry == newValue) { + return; + } + _selectionGeometry = newValue; + notifyListeners(); + } + + SelectionGeometry _getSelectionGeometry() { + if (_textSelectionStart == null || _textSelectionEnd == null) { + return const SelectionGeometry( + status: SelectionStatus.none, + hasContent: true, + ); + } + + final selectionStart = _textSelectionStart!.offset; + final selectionEnd = _textSelectionEnd!.offset; + final isReversed = selectionStart > selectionEnd; + final startOffsetInParagraphCoordinates = + paragraph._getOffsetForPosition(TextPosition(offset: selectionStart)); + final endOffsetInParagraphCoordinates = selectionStart == selectionEnd + ? startOffsetInParagraphCoordinates + : paragraph._getOffsetForPosition(TextPosition(offset: selectionEnd)); + final flipHandles = + isReversed != (TextDirection.rtl == paragraph.textDirection); + final selection = TextSelection( + baseOffset: selectionStart, + extentOffset: selectionEnd, + ); + final selectionRects = []; + for (final textBox in paragraph.getBoxesForSelection(selection)) { + selectionRects.add(textBox.toRect()); + } + return SelectionGeometry( + startSelectionPoint: SelectionPoint( + localPosition: startOffsetInParagraphCoordinates, + lineHeight: paragraph._textPainter.preferredLineHeight, + handleType: flipHandles + ? TextSelectionHandleType.right + : TextSelectionHandleType.left), + endSelectionPoint: SelectionPoint( + localPosition: endOffsetInParagraphCoordinates, + lineHeight: paragraph._textPainter.preferredLineHeight, + handleType: flipHandles + ? TextSelectionHandleType.left + : TextSelectionHandleType.right, + ), + selectionRects: selectionRects, + status: _textSelectionStart!.offset == _textSelectionEnd!.offset + ? SelectionStatus.collapsed + : SelectionStatus.uncollapsed, + hasContent: true, + ); + } + + @override + SelectionResult dispatchSelectionEvent(SelectionEvent event) { + late final SelectionResult result; + final existingSelectionStart = _textSelectionStart; + final existingSelectionEnd = _textSelectionEnd; + switch (event.type) { + case SelectionEventType.startEdgeUpdate: + case SelectionEventType.endEdgeUpdate: + final edgeUpdate = event as SelectionEdgeUpdateEvent; + final granularity = event.granularity; + + switch (granularity) { + case TextGranularity.character: + result = _updateSelectionEdge(edgeUpdate.globalPosition, + isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate); + case TextGranularity.word: + result = _updateSelectionEdgeByTextBoundary( + edgeUpdate.globalPosition, + isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate, + getTextBoundary: _getWordBoundaryAtPosition); + case TextGranularity.paragraph: + result = _updateSelectionEdgeByMultiSelectableTextBoundary( + edgeUpdate.globalPosition, + isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate, + getTextBoundary: _getParagraphBoundaryAtPosition, + getClampedTextBoundary: _getClampedParagraphBoundaryAtPosition); + case TextGranularity.document: + case TextGranularity.line: + assert(false, + 'Moving the selection edge by line or document is not supported.'); + } + case SelectionEventType.clear: + result = _handleClearSelection(); + case SelectionEventType.selectAll: + result = _handleSelectAll(); + case SelectionEventType.selectWord: + final selectWord = event as SelectWordSelectionEvent; + result = _handleSelectWord(selectWord.globalPosition); + case SelectionEventType.selectParagraph: + final selectParagraph = event as SelectParagraphSelectionEvent; + if (selectParagraph.absorb) { + _handleSelectAll(); + result = SelectionResult.next; + _selectableContainsOriginTextBoundary = true; + } else { + result = _handleSelectParagraph(selectParagraph.globalPosition); + } + case SelectionEventType.granularlyExtendSelection: + final granularlyExtendSelection = + event as GranularlyExtendSelectionEvent; + result = _handleGranularlyExtendSelection( + granularlyExtendSelection.forward, + granularlyExtendSelection.isEnd, + granularlyExtendSelection.granularity, + ); + case SelectionEventType.directionallyExtendSelection: + final directionallyExtendSelection = + event as DirectionallyExtendSelectionEvent; + result = _handleDirectionallyExtendSelection( + directionallyExtendSelection.dx, + directionallyExtendSelection.isEnd, + directionallyExtendSelection.direction, + ); + } + + if (existingSelectionStart != _textSelectionStart || + existingSelectionEnd != _textSelectionEnd) { + _didChangeSelection(); + } + return result; + } + + @override + SelectedContent? getSelectedContent() { + if (_textSelectionStart == null || _textSelectionEnd == null) { + return null; + } + final int start = + math.min(_textSelectionStart!.offset, _textSelectionEnd!.offset); + final int end = + math.max(_textSelectionStart!.offset, _textSelectionEnd!.offset); + return SelectedContent( + plainText: fullText.substring(start, end), + ); + } + + void _didChangeSelection() { + paragraph.markNeedsPaint(); + _updateSelectionGeometry(); + } + + TextPosition _updateSelectionStartEdgeByTextBoundary( + _TextBoundaryRecord? textBoundary, + _TextBoundaryAtPosition getTextBoundary, + TextPosition position, + TextPosition? existingSelectionStart, + TextPosition? existingSelectionEnd, + ) { + TextPosition? targetPosition; + if (textBoundary != null) { + assert(textBoundary.boundaryStart.offset >= range.start && + textBoundary.boundaryEnd.offset <= range.end); + if (_selectableContainsOriginTextBoundary && + existingSelectionStart != null && + existingSelectionEnd != null) { + final isSamePosition = position.offset == existingSelectionEnd.offset; + final isSelectionInverted = + existingSelectionStart.offset > existingSelectionEnd.offset; + final shouldSwapEdges = !isSamePosition && + (isSelectionInverted != + (position.offset > existingSelectionEnd.offset)); + if (shouldSwapEdges) { + if (position.offset < existingSelectionEnd.offset) { + targetPosition = textBoundary.boundaryStart; + } else { + targetPosition = textBoundary.boundaryEnd; + } + // When the selection is inverted by the new position it is necessary to + // swap the start edge (moving edge) with the end edge (static edge) to + // maintain the origin text boundary within the selection. + final localTextBoundary = getTextBoundary(existingSelectionEnd); + assert(localTextBoundary.boundaryStart.offset >= range.start && + localTextBoundary.boundaryEnd.offset <= range.end); + _setSelectionPosition( + existingSelectionEnd.offset == + localTextBoundary.boundaryStart.offset + ? localTextBoundary.boundaryEnd + : localTextBoundary.boundaryStart, + isEnd: true); + } else { + if (position.offset < existingSelectionEnd.offset) { + targetPosition = textBoundary.boundaryStart; + } else if (position.offset > existingSelectionEnd.offset) { + targetPosition = textBoundary.boundaryEnd; + } else { + // Keep the origin text boundary in bounds when position is at the static edge. + targetPosition = existingSelectionStart; + } + } + } else { + if (existingSelectionEnd != null) { + // If the end edge exists and the start edge is being moved, then the + // start edge is moved to encompass the entire text boundary at the new position. + if (position.offset < existingSelectionEnd.offset) { + targetPosition = textBoundary.boundaryStart; + } else { + targetPosition = textBoundary.boundaryEnd; + } + } else { + // Move the start edge to the closest text boundary. + targetPosition = _closestTextBoundary(textBoundary, position); + } + } + } else { + // The position is not contained within the current rect. The targetPosition + // will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset] + // for a more in depth explanation on this adjustment. + if (_selectableContainsOriginTextBoundary && + existingSelectionStart != null && + existingSelectionEnd != null) { + // When the selection is inverted by the new position it is necessary to + // swap the start edge (moving edge) with the end edge (static edge) to + // maintain the origin text boundary within the selection. + final isSamePosition = position.offset == existingSelectionEnd.offset; + final isSelectionInverted = + existingSelectionStart.offset > existingSelectionEnd.offset; + final shouldSwapEdges = !isSamePosition && + (isSelectionInverted != + (position.offset > existingSelectionEnd.offset)); + + if (shouldSwapEdges) { + final localTextBoundary = getTextBoundary(existingSelectionEnd); + assert(localTextBoundary.boundaryStart.offset >= range.start && + localTextBoundary.boundaryEnd.offset <= range.end); + _setSelectionPosition( + isSelectionInverted + ? localTextBoundary.boundaryEnd + : localTextBoundary.boundaryStart, + isEnd: true); + } + } + } + return targetPosition ?? position; + } + + TextPosition _updateSelectionEndEdgeByTextBoundary( + _TextBoundaryRecord? textBoundary, + _TextBoundaryAtPosition getTextBoundary, + TextPosition position, + TextPosition? existingSelectionStart, + TextPosition? existingSelectionEnd, + ) { + TextPosition? targetPosition; + if (textBoundary != null) { + assert(textBoundary.boundaryStart.offset >= range.start && + textBoundary.boundaryEnd.offset <= range.end); + if (_selectableContainsOriginTextBoundary && + existingSelectionStart != null && + existingSelectionEnd != null) { + final isSamePosition = position.offset == existingSelectionStart.offset; + final isSelectionInverted = + existingSelectionStart.offset > existingSelectionEnd.offset; + final shouldSwapEdges = !isSamePosition && + (isSelectionInverted != + (position.offset < existingSelectionStart.offset)); + if (shouldSwapEdges) { + if (position.offset < existingSelectionStart.offset) { + targetPosition = textBoundary.boundaryStart; + } else { + targetPosition = textBoundary.boundaryEnd; + } + // When the selection is inverted by the new position it is necessary to + // swap the end edge (moving edge) with the start edge (static edge) to + // maintain the origin text boundary within the selection. + final localTextBoundary = getTextBoundary(existingSelectionStart); + assert(localTextBoundary.boundaryStart.offset >= range.start && + localTextBoundary.boundaryEnd.offset <= range.end); + _setSelectionPosition( + existingSelectionStart.offset == + localTextBoundary.boundaryStart.offset + ? localTextBoundary.boundaryEnd + : localTextBoundary.boundaryStart, + isEnd: false); + } else { + if (position.offset < existingSelectionStart.offset) { + targetPosition = textBoundary.boundaryStart; + } else if (position.offset > existingSelectionStart.offset) { + targetPosition = textBoundary.boundaryEnd; + } else { + // Keep the origin text boundary in bounds when position is at the static edge. + targetPosition = existingSelectionEnd; + } + } + } else { + if (existingSelectionStart != null) { + // If the start edge exists and the end edge is being moved, then the + // end edge is moved to encompass the entire text boundary at the new position. + if (position.offset < existingSelectionStart.offset) { + targetPosition = textBoundary.boundaryStart; + } else { + targetPosition = textBoundary.boundaryEnd; + } + } else { + // Move the end edge to the closest text boundary. + targetPosition = _closestTextBoundary(textBoundary, position); + } + } + } else { + // The position is not contained within the current rect. The targetPosition + // will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset] + // for a more in depth explanation on this adjustment. + if (_selectableContainsOriginTextBoundary && + existingSelectionStart != null && + existingSelectionEnd != null) { + // When the selection is inverted by the new position it is necessary to + // swap the end edge (moving edge) with the start edge (static edge) to + // maintain the origin text boundary within the selection. + final isSamePosition = position.offset == existingSelectionStart.offset; + final isSelectionInverted = + existingSelectionStart.offset > existingSelectionEnd.offset; + final shouldSwapEdges = isSelectionInverted != + (position.offset < existingSelectionStart.offset) || + isSamePosition; + if (shouldSwapEdges) { + final localTextBoundary = getTextBoundary(existingSelectionStart); + assert(localTextBoundary.boundaryStart.offset >= range.start && + localTextBoundary.boundaryEnd.offset <= range.end); + _setSelectionPosition( + isSelectionInverted + ? localTextBoundary.boundaryStart + : localTextBoundary.boundaryEnd, + isEnd: false); + } + } + } + return targetPosition ?? position; + } + + SelectionResult _updateSelectionEdgeByTextBoundary(Offset globalPosition, + {required bool isEnd, required _TextBoundaryAtPosition getTextBoundary}) { + // When the start/end edges are swapped, i.e. the start is after the end, and + // the scrollable synthesizes an event for the opposite edge, this will potentially + // move the opposite edge outside of the origin text boundary and we are unable to recover. + final existingSelectionStart = _textSelectionStart; + final existingSelectionEnd = _textSelectionEnd; + + _setSelectionPosition(null, isEnd: isEnd); + final transform = paragraph.getTransformTo(null); + transform.invert(); + final localPosition = MatrixUtils.transformPoint(transform, globalPosition); + if (_rect.isEmpty) { + return SelectionUtils.getResultBasedOnRect(_rect, localPosition); + } + final adjustedOffset = SelectionUtils.adjustDragOffset( + _rect, + localPosition, + direction: paragraph.textDirection, + ); + + final position = paragraph.getPositionForOffset(adjustedOffset); + // Check if the original local position is within the rect, if it is not then + // we do not need to look up the text boundary for that position. This is to + // maintain a selectables selection collapsed at 0 when the local position is + // not located inside its rect. + var textBoundary = + _rect.contains(localPosition) ? getTextBoundary(position) : null; + if (textBoundary != null && + (textBoundary.boundaryStart.offset < range.start && + textBoundary.boundaryEnd.offset <= range.start || + textBoundary.boundaryStart.offset >= range.end && + textBoundary.boundaryEnd.offset > range.end)) { + // When the position is located at a placeholder inside of the text, then we may compute + // a text boundary that does not belong to the current selectable fragment. In this case + // we should invalidate the text boundary so that it is not taken into account when + // computing the target position. + textBoundary = null; + } + final targetPosition = _clampTextPosition(isEnd + ? _updateSelectionEndEdgeByTextBoundary(textBoundary, getTextBoundary, + position, existingSelectionStart, existingSelectionEnd) + : _updateSelectionStartEdgeByTextBoundary(textBoundary, getTextBoundary, + position, existingSelectionStart, existingSelectionEnd)); + + _setSelectionPosition(targetPosition, isEnd: isEnd); + if (targetPosition.offset == range.end) { + return SelectionResult.next; + } + + if (targetPosition.offset == range.start) { + return SelectionResult.previous; + } + // TODO(chunhtai): The geometry information should not be used to determine + // selection result. This is a workaround to RenderParagraph, where it does + // not have a way to get accurate text length if its text is truncated due to + // layout constraint. + return SelectionUtils.getResultBasedOnRect(_rect, localPosition); + } + + SelectionResult _updateSelectionEdge(Offset globalPosition, + {required bool isEnd}) { + _setSelectionPosition(null, isEnd: isEnd); + final transform = paragraph.getTransformTo(null); + transform.invert(); + final localPosition = MatrixUtils.transformPoint(transform, globalPosition); + if (_rect.isEmpty) { + return SelectionUtils.getResultBasedOnRect(_rect, localPosition); + } + final adjustedOffset = SelectionUtils.adjustDragOffset( + _rect, + localPosition, + direction: paragraph.textDirection, + ); + + final position = + _clampTextPosition(paragraph.getPositionForOffset(adjustedOffset)); + _setSelectionPosition(position, isEnd: isEnd); + if (position.offset == range.end) { + return SelectionResult.next; + } + if (position.offset == range.start) { + return SelectionResult.previous; + } + // TODO(chunhtai): The geometry information should not be used to determine + // selection result. This is a workaround to RenderParagraph, where it does + // not have a way to get accurate text length if its text is truncated due to + // layout constraint. + return SelectionUtils.getResultBasedOnRect(_rect, localPosition); + } + + // This method handles updating the start edge by a text boundary that may + // not be contained within this selectable fragment. It is possible + // that a boundary spans multiple selectable fragments when the text contains + // [WidgetSpan]s. + // + // This method differs from [_updateSelectionStartEdgeByTextBoundary] in that + // to pivot offset used to swap selection edges and maintain the origin + // text boundary selected may be located outside of this selectable fragment. + // + // See [_updateSelectionEndEdgeByMultiSelectableTextBoundary] for the method + // that handles updating the end edge. + SelectionResult? _updateSelectionStartEdgeByMultiSelectableTextBoundary( + _TextBoundaryAtPositionInText getTextBoundary, + bool paragraphContainsPosition, + TextPosition position, + TextPosition? existingSelectionStart, + TextPosition? existingSelectionEnd, + ) { + const isEnd = false; + if (_selectableContainsOriginTextBoundary && + existingSelectionStart != null && + existingSelectionEnd != null) { + // If this selectable contains the origin boundary, maintain the existing + // selection. + final forwardSelection = + existingSelectionEnd.offset >= existingSelectionStart.offset; + if (paragraphContainsPosition) { + // When the position is within the root paragraph, swap the start and end + // edges when the selection is inverted. + final boundaryAtPosition = getTextBoundary(position, fullText); + // To accurately retrieve the origin text boundary when the selection + // is forward, use existingSelectionEnd.offset - 1. This is necessary + // because in a forwards selection, existingSelectionEnd marks the end + // of the origin text boundary. Using the unmodified offset incorrectly + // targets the subsequent text boundary. + final originTextBoundary = getTextBoundary( + forwardSelection + ? TextPosition( + offset: existingSelectionEnd.offset - 1, + affinity: existingSelectionEnd.affinity, + ) + : existingSelectionEnd, + fullText, + ); + final TextPosition targetPosition; + final pivotOffset = forwardSelection + ? originTextBoundary.boundaryEnd.offset + : originTextBoundary.boundaryStart.offset; + final shouldSwapEdges = + !forwardSelection != (position.offset > pivotOffset); + if (position.offset < pivotOffset) { + targetPosition = boundaryAtPosition.boundaryStart; + } else if (position.offset > pivotOffset) { + targetPosition = boundaryAtPosition.boundaryEnd; + } else { + // Keep the origin text boundary in bounds when position is at the static edge. + targetPosition = + forwardSelection ? existingSelectionStart : existingSelectionEnd; + } + if (shouldSwapEdges) { + _setSelectionPosition( + _clampTextPosition(forwardSelection + ? originTextBoundary.boundaryStart + : originTextBoundary.boundaryEnd), + isEnd: true, + ); + } + _setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd); + final finalSelectionIsForward = + _textSelectionEnd!.offset >= _textSelectionStart!.offset; + if (boundaryAtPosition.boundaryStart.offset > range.end && + boundaryAtPosition.boundaryEnd.offset > range.end) { + return SelectionResult.next; + } + if (boundaryAtPosition.boundaryStart.offset < range.start && + boundaryAtPosition.boundaryEnd.offset < range.start) { + return SelectionResult.previous; + } + if (finalSelectionIsForward) { + if (boundaryAtPosition.boundaryStart.offset >= + originTextBoundary.boundaryStart.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryStart.offset < + originTextBoundary.boundaryStart.offset) { + return SelectionResult.previous; + } + } else { + if (boundaryAtPosition.boundaryEnd.offset <= + originTextBoundary.boundaryEnd.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryEnd.offset > + originTextBoundary.boundaryEnd.offset) { + return SelectionResult.next; + } + } + } else { + // When the drag position is not contained within the root paragraph, + // swap the edges when the selection changes direction. + final clampedPosition = _clampTextPosition(position); + // To accurately retrieve the origin text boundary when the selection + // is forward, use existingSelectionEnd.offset - 1. This is necessary + // because in a forwards selection, existingSelectionEnd marks the end + // of the origin text boundary. Using the unmodified offset incorrectly + // targets the subsequent text boundary. + final originTextBoundary = getTextBoundary( + forwardSelection + ? TextPosition( + offset: existingSelectionEnd.offset - 1, + affinity: existingSelectionEnd.affinity, + ) + : existingSelectionEnd, + fullText, + ); + if (forwardSelection && clampedPosition.offset == range.start) { + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.previous; + } + if (!forwardSelection && clampedPosition.offset == range.end) { + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.next; + } + if (forwardSelection && clampedPosition.offset == range.end) { + _setSelectionPosition( + _clampTextPosition(originTextBoundary.boundaryStart), + isEnd: true); + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.next; + } + if (!forwardSelection && clampedPosition.offset == range.start) { + _setSelectionPosition( + _clampTextPosition(originTextBoundary.boundaryEnd), + isEnd: true); + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.previous; + } + } + } else { + // A paragraph boundary may not be completely contained within this root + // selectable fragment. Keep searching until we find the end of the + // boundary. Do not search when the current drag position is on a placeholder + // to allow traversal to reach that placeholder. + final positionOnPlaceholder = + paragraph.getWordBoundary(position).textInside(fullText) == + _placeholderCharacter; + if (!paragraphContainsPosition || positionOnPlaceholder) { + return null; + } + if (existingSelectionEnd != null) { + final boundaryAtPosition = getTextBoundary(position, fullText); + final backwardSelection = existingSelectionStart == null && + existingSelectionEnd.offset == range.start || + existingSelectionStart == existingSelectionEnd && + existingSelectionEnd.offset == range.start || + existingSelectionStart != null && + existingSelectionStart.offset > existingSelectionEnd.offset; + if (boundaryAtPosition.boundaryStart.offset < range.start && + boundaryAtPosition.boundaryEnd.offset < range.start) { + _setSelectionPosition(TextPosition(offset: range.start), + isEnd: isEnd); + return SelectionResult.previous; + } + if (boundaryAtPosition.boundaryStart.offset > range.end && + boundaryAtPosition.boundaryEnd.offset > range.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (backwardSelection) { + if (boundaryAtPosition.boundaryEnd.offset <= range.end) { + _setSelectionPosition( + _clampTextPosition(boundaryAtPosition.boundaryEnd), + isEnd: isEnd); + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryEnd.offset > range.end) { + _setSelectionPosition(TextPosition(offset: range.end), + isEnd: isEnd); + return SelectionResult.next; + } + } else { + _setSelectionPosition( + _clampTextPosition(boundaryAtPosition.boundaryStart), + isEnd: isEnd); + if (boundaryAtPosition.boundaryStart.offset < range.start) { + return SelectionResult.previous; + } + if (boundaryAtPosition.boundaryStart.offset >= range.start) { + return SelectionResult.end; + } + } + } + } + return null; + } + + // This method handles updating the end edge by a text boundary that may + // not be contained within this selectable fragment. It is possible + // that a boundary spans multiple selectable fragments when the text contains + // [WidgetSpan]s. + // + // This method differs from [_updateSelectionEndEdgeByTextBoundary] in that + // to pivot offset used to swap selection edges and maintain the origin + // text boundary selected may be located outside of this selectable fragment. + // + // See [_updateSelectionStartEdgeByMultiSelectableTextBoundary] for the method + // that handles updating the end edge. + SelectionResult? _updateSelectionEndEdgeByMultiSelectableTextBoundary( + _TextBoundaryAtPositionInText getTextBoundary, + bool paragraphContainsPosition, + TextPosition position, + TextPosition? existingSelectionStart, + TextPosition? existingSelectionEnd, + ) { + const isEnd = true; + if (_selectableContainsOriginTextBoundary && + existingSelectionStart != null && + existingSelectionEnd != null) { + // If this selectable contains the origin boundary, maintain the existing + // selection. + final forwardSelection = + existingSelectionEnd.offset >= existingSelectionStart.offset; + if (paragraphContainsPosition) { + // When the position is within the root paragraph, swap the start and end + // edges when the selection is inverted. + final boundaryAtPosition = getTextBoundary(position, fullText); + // To accurately retrieve the origin text boundary when the selection + // is backwards, use existingSelectionStart.offset - 1. This is necessary + // because in a backwards selection, existingSelectionStart marks the end + // of the origin text boundary. Using the unmodified offset incorrectly + // targets the subsequent text boundary. + final originTextBoundary = getTextBoundary( + forwardSelection + ? existingSelectionStart + : TextPosition( + offset: existingSelectionStart.offset - 1, + affinity: existingSelectionStart.affinity, + ), + fullText, + ); + final TextPosition targetPosition; + final pivotOffset = forwardSelection + ? originTextBoundary.boundaryStart.offset + : originTextBoundary.boundaryEnd.offset; + final shouldSwapEdges = + !forwardSelection != (position.offset < pivotOffset); + if (position.offset < pivotOffset) { + targetPosition = boundaryAtPosition.boundaryStart; + } else if (position.offset > pivotOffset) { + targetPosition = boundaryAtPosition.boundaryEnd; + } else { + // Keep the origin text boundary in bounds when position is at the static edge. + targetPosition = + forwardSelection ? existingSelectionEnd : existingSelectionStart; + } + if (shouldSwapEdges) { + _setSelectionPosition( + _clampTextPosition(forwardSelection + ? originTextBoundary.boundaryEnd + : originTextBoundary.boundaryStart), + isEnd: false, + ); + } + _setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd); + final finalSelectionIsForward = + _textSelectionEnd!.offset >= _textSelectionStart!.offset; + if (boundaryAtPosition.boundaryStart.offset > range.end && + boundaryAtPosition.boundaryEnd.offset > range.end) { + return SelectionResult.next; + } + if (boundaryAtPosition.boundaryStart.offset < range.start && + boundaryAtPosition.boundaryEnd.offset < range.start) { + return SelectionResult.previous; + } + if (finalSelectionIsForward) { + if (boundaryAtPosition.boundaryEnd.offset <= + originTextBoundary.boundaryEnd.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryEnd.offset > + originTextBoundary.boundaryEnd.offset) { + return SelectionResult.next; + } + } else { + if (boundaryAtPosition.boundaryStart.offset >= + originTextBoundary.boundaryStart.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryStart.offset < + originTextBoundary.boundaryStart.offset) { + return SelectionResult.previous; + } + } + } else { + // When the drag position is not contained within the root paragraph, + // swap the edges when the selection changes direction. + final clampedPosition = _clampTextPosition(position); + // To accurately retrieve the origin text boundary when the selection + // is backwards, use existingSelectionStart.offset - 1. This is necessary + // because in a backwards selection, existingSelectionStart marks the end + // of the origin text boundary. Using the unmodified offset incorrectly + // targets the subsequent text boundary. + final originTextBoundary = getTextBoundary( + forwardSelection + ? existingSelectionStart + : TextPosition( + offset: existingSelectionStart.offset - 1, + affinity: existingSelectionStart.affinity, + ), + fullText, + ); + if (forwardSelection && clampedPosition.offset == range.start) { + _setSelectionPosition( + _clampTextPosition(originTextBoundary.boundaryEnd), + isEnd: false); + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.previous; + } + if (!forwardSelection && clampedPosition.offset == range.end) { + _setSelectionPosition( + _clampTextPosition(originTextBoundary.boundaryStart), + isEnd: false); + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.next; + } + if (forwardSelection && clampedPosition.offset == range.end) { + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.next; + } + if (!forwardSelection && clampedPosition.offset == range.start) { + _setSelectionPosition(clampedPosition, isEnd: isEnd); + return SelectionResult.previous; + } + } + } else { + // A paragraph boundary may not be completely contained within this root + // selectable fragment. Keep searching until we find the end of the + // boundary. Do not search when the current drag position is on a placeholder + // to allow traversal to reach that placeholder. + final positionOnPlaceholder = + paragraph.getWordBoundary(position).textInside(fullText) == + _placeholderCharacter; + if (!paragraphContainsPosition || positionOnPlaceholder) { + return null; + } + if (existingSelectionStart != null) { + final boundaryAtPosition = getTextBoundary(position, fullText); + final backwardSelection = existingSelectionEnd == null && + existingSelectionStart.offset == range.end || + existingSelectionStart == existingSelectionEnd && + existingSelectionStart.offset == range.end || + existingSelectionEnd != null && + existingSelectionStart.offset > existingSelectionEnd.offset; + if (boundaryAtPosition.boundaryStart.offset < range.start && + boundaryAtPosition.boundaryEnd.offset < range.start) { + _setSelectionPosition(TextPosition(offset: range.start), + isEnd: isEnd); + return SelectionResult.previous; + } + if (boundaryAtPosition.boundaryStart.offset > range.end && + boundaryAtPosition.boundaryEnd.offset > range.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (backwardSelection) { + _setSelectionPosition( + _clampTextPosition(boundaryAtPosition.boundaryStart), + isEnd: isEnd); + if (boundaryAtPosition.boundaryStart.offset < range.start) { + return SelectionResult.previous; + } + if (boundaryAtPosition.boundaryStart.offset >= range.start) { + return SelectionResult.end; + } + } else { + if (boundaryAtPosition.boundaryEnd.offset <= range.end) { + _setSelectionPosition( + _clampTextPosition(boundaryAtPosition.boundaryEnd), + isEnd: isEnd); + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryEnd.offset > range.end) { + _setSelectionPosition(TextPosition(offset: range.end), + isEnd: isEnd); + return SelectionResult.next; + } + } + } + } + return null; + } + + // The placeholder character used by [RenderParagraph]. + static final String _placeholderCharacter = + String.fromCharCode(PlaceholderSpan.placeholderCodeUnit); + static final int _placeholderLength = _placeholderCharacter.length; + + // This method handles updating the start edge by a text boundary that may + // not be contained within this selectable fragment. It is possible + // that a boundary spans multiple selectable fragments when the text contains + // [WidgetSpan]s. + // + // This method differs from [_updateSelectionStartEdgeByMultiSelectableBoundary] + // in that to maintain the origin text boundary selected at a placeholder, + // this selectable fragment must be aware of the [RenderParagraph] that closely + // encompasses the complete origin text boundary. + // + // See [_updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary] for the method + // that handles updating the end edge. + SelectionResult? + _updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary( + _TextBoundaryAtPositionInText getTextBoundary, + Offset globalPosition, + bool paragraphContainsPosition, + TextPosition position, + TextPosition? existingSelectionStart, + TextPosition? existingSelectionEnd, + ) { + const isEnd = false; + if (_selectableContainsOriginTextBoundary && + existingSelectionStart != null && + existingSelectionEnd != null) { + // If this selectable contains the origin boundary, maintain the existing + // selection. + final forwardSelection = + existingSelectionEnd.offset >= existingSelectionStart.offset; + final originParagraph = _getOriginParagraph(); + final fragmentBelongsToOriginParagraph = originParagraph == paragraph; + if (fragmentBelongsToOriginParagraph) { + return _updateSelectionStartEdgeByMultiSelectableTextBoundary( + getTextBoundary, + paragraphContainsPosition, + position, + existingSelectionStart, + existingSelectionEnd, + ); + } + final originTransform = originParagraph.getTransformTo(null); + originTransform.invert(); + final originParagraphLocalPosition = + MatrixUtils.transformPoint(originTransform, globalPosition); + final positionWithinOriginParagraph = + originParagraph.paintBounds.contains(originParagraphLocalPosition); + final positionRelativeToOriginParagraph = + originParagraph.getPositionForOffset(originParagraphLocalPosition); + if (positionWithinOriginParagraph) { + // When the selection is inverted by the new position it is necessary to + // swap the start edge (moving edge) with the end edge (static edge) to + // maintain the origin text boundary within the selection. + final originText = + originParagraph.text.toPlainText(includeSemanticsLabels: false); + final boundaryAtPosition = + getTextBoundary(positionRelativeToOriginParagraph, originText); + final originTextBoundary = getTextBoundary( + _getPositionInParagraph(originParagraph), originText); + final TextPosition targetPosition; + final pivotOffset = forwardSelection + ? originTextBoundary.boundaryEnd.offset + : originTextBoundary.boundaryStart.offset; + final shouldSwapEdges = !forwardSelection != + (positionRelativeToOriginParagraph.offset > pivotOffset); + if (positionRelativeToOriginParagraph.offset < pivotOffset) { + targetPosition = boundaryAtPosition.boundaryStart; + } else if (positionRelativeToOriginParagraph.offset > pivotOffset) { + targetPosition = boundaryAtPosition.boundaryEnd; + } else { + // Keep the origin text boundary in bounds when position is at the static edge. + targetPosition = existingSelectionStart; + } + if (shouldSwapEdges) { + _setSelectionPosition(existingSelectionStart, isEnd: true); + } + _setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd); + final finalSelectionIsForward = + _textSelectionEnd!.offset >= _textSelectionStart!.offset; + final originParagraphPlaceholderTextPosition = + _getPositionInParagraph(originParagraph); + final originParagraphPlaceholderRange = TextRange( + start: originParagraphPlaceholderTextPosition.offset, + end: originParagraphPlaceholderTextPosition.offset + + _placeholderLength); + if (boundaryAtPosition.boundaryStart.offset > + originParagraphPlaceholderRange.end && + boundaryAtPosition.boundaryEnd.offset > + originParagraphPlaceholderRange.end) { + return SelectionResult.next; + } + if (boundaryAtPosition.boundaryStart.offset < + originParagraphPlaceholderRange.start && + boundaryAtPosition.boundaryEnd.offset < + originParagraphPlaceholderRange.start) { + return SelectionResult.previous; + } + if (finalSelectionIsForward) { + if (boundaryAtPosition.boundaryEnd.offset <= + originTextBoundary.boundaryEnd.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryEnd.offset > + originTextBoundary.boundaryEnd.offset) { + return SelectionResult.next; + } + } else { + if (boundaryAtPosition.boundaryStart.offset >= + originTextBoundary.boundaryStart.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryStart.offset < + originTextBoundary.boundaryStart.offset) { + return SelectionResult.previous; + } + } + } else { + // When the drag position is not contained within the origin paragraph, + // swap the edges when the selection changes direction. + // + // [SelectionUtils.adjustDragOffset] will adjust the given [Offset] to the + // beginning or end of the provided [Rect] based on whether the [Offset] + // is located within the given [Rect]. + final adjustedOffset = SelectionUtils.adjustDragOffset( + originParagraph.paintBounds, + originParagraphLocalPosition, + direction: paragraph.textDirection, + ); + final adjustedPositionRelativeToOriginParagraph = + originParagraph.getPositionForOffset(adjustedOffset); + final originParagraphPlaceholderTextPosition = + _getPositionInParagraph(originParagraph); + final originParagraphPlaceholderRange = TextRange( + start: originParagraphPlaceholderTextPosition.offset, + end: originParagraphPlaceholderTextPosition.offset + + _placeholderLength); + if (forwardSelection && + adjustedPositionRelativeToOriginParagraph.offset <= + originParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), + isEnd: isEnd); + return SelectionResult.previous; + } + if (!forwardSelection && + adjustedPositionRelativeToOriginParagraph.offset >= + originParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (forwardSelection && + adjustedPositionRelativeToOriginParagraph.offset >= + originParagraphPlaceholderRange.end) { + _setSelectionPosition(existingSelectionStart, isEnd: true); + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (!forwardSelection && + adjustedPositionRelativeToOriginParagraph.offset <= + originParagraphPlaceholderRange.start) { + _setSelectionPosition(existingSelectionStart, isEnd: true); + _setSelectionPosition(TextPosition(offset: range.start), + isEnd: isEnd); + return SelectionResult.previous; + } + } + } else { + // When the drag position is somewhere on the root text and not a placeholder, + // traverse the selectable fragments relative to the [RenderParagraph] that + // contains the drag position. + if (paragraphContainsPosition) { + return _updateSelectionStartEdgeByMultiSelectableTextBoundary( + getTextBoundary, + paragraphContainsPosition, + position, + existingSelectionStart, + existingSelectionEnd, + ); + } + if (existingSelectionEnd != null) { + final targetDetails = _getParagraphContainingPosition(globalPosition); + if (targetDetails == null) { + return null; + } + final targetParagraph = targetDetails.paragraph; + final positionRelativeToTargetParagraph = + targetParagraph.getPositionForOffset(targetDetails.localPosition); + final targetText = + targetParagraph.text.toPlainText(includeSemanticsLabels: false); + final positionOnPlaceholder = targetParagraph + .getWordBoundary(positionRelativeToTargetParagraph) + .textInside(targetText) == + _placeholderCharacter; + if (positionOnPlaceholder) { + return null; + } + final backwardSelection = existingSelectionStart == null && + existingSelectionEnd.offset == range.start || + existingSelectionStart == existingSelectionEnd && + existingSelectionEnd.offset == range.start || + existingSelectionStart != null && + existingSelectionStart.offset > existingSelectionEnd.offset; + final boundaryAtPositionRelativeToTargetParagraph = + getTextBoundary(positionRelativeToTargetParagraph, targetText); + final targetParagraphPlaceholderTextPosition = + _getPositionInParagraph(targetParagraph); + final targetParagraphPlaceholderRange = TextRange( + start: targetParagraphPlaceholderTextPosition.offset, + end: targetParagraphPlaceholderTextPosition.offset + + _placeholderLength); + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < + targetParagraphPlaceholderRange.start && + boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset < + targetParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), + isEnd: isEnd); + return SelectionResult.previous; + } + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset > + targetParagraphPlaceholderRange.end && + boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > + targetParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (backwardSelection) { + if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset <= + targetParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), + isEnd: isEnd); + return SelectionResult.end; + } + if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > + targetParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), + isEnd: isEnd); + return SelectionResult.next; + } + } else { + if (boundaryAtPositionRelativeToTargetParagraph + .boundaryStart.offset >= + targetParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), + isEnd: isEnd); + return SelectionResult.end; + } + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < + targetParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), + isEnd: isEnd); + return SelectionResult.previous; + } + } + } + } + return null; + } + + // This method handles updating the end edge by a text boundary that may + // not be contained within this selectable fragment. It is possible + // that a boundary spans multiple selectable fragments when the text contains + // [WidgetSpan]s. + // + // This method differs from [_updateSelectionEndEdgeByMultiSelectableBoundary] + // in that to maintain the origin text boundary selected at a placeholder, this + // selectable fragment must be aware of the [RenderParagraph] that closely + // encompasses the complete origin text boundary. + // + // See [_updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary] + // for the method that handles updating the start edge. + SelectionResult? + _updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary( + _TextBoundaryAtPositionInText getTextBoundary, + Offset globalPosition, + bool paragraphContainsPosition, + TextPosition position, + TextPosition? existingSelectionStart, + TextPosition? existingSelectionEnd, + ) { + const isEnd = true; + if (_selectableContainsOriginTextBoundary && + existingSelectionStart != null && + existingSelectionEnd != null) { + // If this selectable contains the origin boundary, maintain the existing + // selection. + final forwardSelection = + existingSelectionEnd.offset >= existingSelectionStart.offset; + final originParagraph = _getOriginParagraph(); + final fragmentBelongsToOriginParagraph = originParagraph == paragraph; + if (fragmentBelongsToOriginParagraph) { + return _updateSelectionEndEdgeByMultiSelectableTextBoundary( + getTextBoundary, + paragraphContainsPosition, + position, + existingSelectionStart, + existingSelectionEnd, + ); + } + final originTransform = originParagraph.getTransformTo(null); + originTransform.invert(); + final originParagraphLocalPosition = + MatrixUtils.transformPoint(originTransform, globalPosition); + final positionWithinOriginParagraph = + originParagraph.paintBounds.contains(originParagraphLocalPosition); + final positionRelativeToOriginParagraph = + originParagraph.getPositionForOffset(originParagraphLocalPosition); + if (positionWithinOriginParagraph) { + // When the selection is inverted by the new position it is necessary to + // swap the end edge (moving edge) with the start edge (static edge) to + // maintain the origin text boundary within the selection. + final originText = + originParagraph.text.toPlainText(includeSemanticsLabels: false); + final boundaryAtPosition = + getTextBoundary(positionRelativeToOriginParagraph, originText); + final originTextBoundary = getTextBoundary( + _getPositionInParagraph(originParagraph), originText); + final TextPosition targetPosition; + final pivotOffset = forwardSelection + ? originTextBoundary.boundaryStart.offset + : originTextBoundary.boundaryEnd.offset; + final shouldSwapEdges = !forwardSelection != + (positionRelativeToOriginParagraph.offset < pivotOffset); + if (positionRelativeToOriginParagraph.offset < pivotOffset) { + targetPosition = boundaryAtPosition.boundaryStart; + } else if (positionRelativeToOriginParagraph.offset > pivotOffset) { + targetPosition = boundaryAtPosition.boundaryEnd; + } else { + // Keep the origin text boundary in bounds when position is at the static edge. + targetPosition = existingSelectionEnd; + } + if (shouldSwapEdges) { + _setSelectionPosition(existingSelectionEnd, isEnd: false); + } + _setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd); + final finalSelectionIsForward = + _textSelectionEnd!.offset >= _textSelectionStart!.offset; + final originParagraphPlaceholderTextPosition = + _getPositionInParagraph(originParagraph); + final originParagraphPlaceholderRange = TextRange( + start: originParagraphPlaceholderTextPosition.offset, + end: originParagraphPlaceholderTextPosition.offset + + _placeholderLength); + if (boundaryAtPosition.boundaryStart.offset > + originParagraphPlaceholderRange.end && + boundaryAtPosition.boundaryEnd.offset > + originParagraphPlaceholderRange.end) { + return SelectionResult.next; + } + if (boundaryAtPosition.boundaryStart.offset < + originParagraphPlaceholderRange.start && + boundaryAtPosition.boundaryEnd.offset < + originParagraphPlaceholderRange.start) { + return SelectionResult.previous; + } + if (finalSelectionIsForward) { + if (boundaryAtPosition.boundaryEnd.offset <= + originTextBoundary.boundaryEnd.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryEnd.offset > + originTextBoundary.boundaryEnd.offset) { + return SelectionResult.next; + } + } else { + if (boundaryAtPosition.boundaryStart.offset >= + originTextBoundary.boundaryStart.offset) { + return SelectionResult.end; + } + if (boundaryAtPosition.boundaryStart.offset < + originTextBoundary.boundaryStart.offset) { + return SelectionResult.previous; + } + } + } else { + // When the drag position is not contained within the origin paragraph, + // swap the edges when the selection changes direction. + // + // [SelectionUtils.adjustDragOffset] will adjust the given [Offset] to the + // beginning or end of the provided [Rect] based on whether the [Offset] + // is located within the given [Rect]. + final adjustedOffset = SelectionUtils.adjustDragOffset( + originParagraph.paintBounds, + originParagraphLocalPosition, + direction: paragraph.textDirection, + ); + final adjustedPositionRelativeToOriginParagraph = + originParagraph.getPositionForOffset(adjustedOffset); + final originParagraphPlaceholderTextPosition = + _getPositionInParagraph(originParagraph); + final originParagraphPlaceholderRange = TextRange( + start: originParagraphPlaceholderTextPosition.offset, + end: originParagraphPlaceholderTextPosition.offset + + _placeholderLength); + if (forwardSelection && + adjustedPositionRelativeToOriginParagraph.offset <= + originParagraphPlaceholderRange.start) { + _setSelectionPosition(existingSelectionEnd, isEnd: false); + _setSelectionPosition(TextPosition(offset: range.start), + isEnd: isEnd); + return SelectionResult.previous; + } + if (!forwardSelection && + adjustedPositionRelativeToOriginParagraph.offset >= + originParagraphPlaceholderRange.end) { + _setSelectionPosition(existingSelectionEnd, isEnd: false); + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (forwardSelection && + adjustedPositionRelativeToOriginParagraph.offset >= + originParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (!forwardSelection && + adjustedPositionRelativeToOriginParagraph.offset <= + originParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), + isEnd: isEnd); + return SelectionResult.previous; + } + } + } else { + // When the drag position is somewhere on the root text and not a placeholder, + // traverse the selectable fragments relative to the [RenderParagraph] that + // contains the drag position. + if (paragraphContainsPosition) { + return _updateSelectionEndEdgeByMultiSelectableTextBoundary( + getTextBoundary, + paragraphContainsPosition, + position, + existingSelectionStart, + existingSelectionEnd, + ); + } + if (existingSelectionStart != null) { + final targetDetails = _getParagraphContainingPosition(globalPosition); + if (targetDetails == null) { + return null; + } + final targetParagraph = targetDetails.paragraph; + final positionRelativeToTargetParagraph = + targetParagraph.getPositionForOffset(targetDetails.localPosition); + final targetText = + targetParagraph.text.toPlainText(includeSemanticsLabels: false); + final positionOnPlaceholder = targetParagraph + .getWordBoundary(positionRelativeToTargetParagraph) + .textInside(targetText) == + _placeholderCharacter; + if (positionOnPlaceholder) { + return null; + } + final backwardSelection = existingSelectionEnd == null && + existingSelectionStart.offset == range.end || + existingSelectionStart == existingSelectionEnd && + existingSelectionStart.offset == range.end || + existingSelectionEnd != null && + existingSelectionStart.offset > existingSelectionEnd.offset; + final boundaryAtPositionRelativeToTargetParagraph = + getTextBoundary(positionRelativeToTargetParagraph, targetText); + final targetParagraphPlaceholderTextPosition = + _getPositionInParagraph(targetParagraph); + final targetParagraphPlaceholderRange = TextRange( + start: targetParagraphPlaceholderTextPosition.offset, + end: targetParagraphPlaceholderTextPosition.offset + + _placeholderLength); + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < + targetParagraphPlaceholderRange.start && + boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset < + targetParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), + isEnd: isEnd); + return SelectionResult.previous; + } + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset > + targetParagraphPlaceholderRange.end && + boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > + targetParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd); + return SelectionResult.next; + } + if (backwardSelection) { + if (boundaryAtPositionRelativeToTargetParagraph + .boundaryStart.offset >= + targetParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), + isEnd: isEnd); + return SelectionResult.end; + } + if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset < + targetParagraphPlaceholderRange.start) { + _setSelectionPosition(TextPosition(offset: range.start), + isEnd: isEnd); + return SelectionResult.previous; + } + } else { + if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset <= + targetParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), + isEnd: isEnd); + return SelectionResult.end; + } + if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset > + targetParagraphPlaceholderRange.end) { + _setSelectionPosition(TextPosition(offset: range.end), + isEnd: isEnd); + return SelectionResult.next; + } + } + } + } + return null; + } + + SelectionResult _updateSelectionEdgeByMultiSelectableTextBoundary( + Offset globalPosition, { + required bool isEnd, + required _TextBoundaryAtPositionInText getTextBoundary, + required _TextBoundaryAtPosition getClampedTextBoundary, + }) { + // When the start/end edges are swapped, i.e. the start is after the end, and + // the scrollable synthesizes an event for the opposite edge, this will potentially + // move the opposite edge outside of the origin text boundary and we are unable to recover. + final existingSelectionStart = _textSelectionStart; + final existingSelectionEnd = _textSelectionEnd; + + _setSelectionPosition(null, isEnd: isEnd); + final transform = paragraph.getTransformTo(null); + transform.invert(); + final localPosition = MatrixUtils.transformPoint(transform, globalPosition); + if (_rect.isEmpty) { + return SelectionUtils.getResultBasedOnRect(_rect, localPosition); + } + final adjustedOffset = SelectionUtils.adjustDragOffset( + _rect, + localPosition, + direction: paragraph.textDirection, + ); + final adjustedOffsetRelativeToParagraph = SelectionUtils.adjustDragOffset( + paragraph.paintBounds, + localPosition, + direction: paragraph.textDirection, + ); + + final position = paragraph.getPositionForOffset(adjustedOffset); + final positionInFullText = + paragraph.getPositionForOffset(adjustedOffsetRelativeToParagraph); + + final SelectionResult? result; + if (_isPlaceholder()) { + result = isEnd + ? _updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary( + getTextBoundary, + globalPosition, + paragraph.paintBounds.contains(localPosition), + positionInFullText, + existingSelectionStart, + existingSelectionEnd, + ) + : _updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary( + getTextBoundary, + globalPosition, + paragraph.paintBounds.contains(localPosition), + positionInFullText, + existingSelectionStart, + existingSelectionEnd, + ); + } else { + result = isEnd + ? _updateSelectionEndEdgeByMultiSelectableTextBoundary( + getTextBoundary, + paragraph.paintBounds.contains(localPosition), + positionInFullText, + existingSelectionStart, + existingSelectionEnd, + ) + : _updateSelectionStartEdgeByMultiSelectableTextBoundary( + getTextBoundary, + paragraph.paintBounds.contains(localPosition), + positionInFullText, + existingSelectionStart, + existingSelectionEnd, + ); + } + if (result != null) { + return result; + } + + // Check if the original local position is within the rect, if it is not then + // we do not need to look up the text boundary for that position. This is to + // maintain a selectables selection collapsed at 0 when the local position is + // not located inside its rect. + var textBoundary = _boundingBoxesContains(localPosition) + ? getClampedTextBoundary(position) + : null; + if (textBoundary != null && + (textBoundary.boundaryStart.offset < range.start && + textBoundary.boundaryEnd.offset <= range.start || + textBoundary.boundaryStart.offset >= range.end && + textBoundary.boundaryEnd.offset > range.end)) { + // When the position is located at a placeholder inside of the text, then we may compute + // a text boundary that does not belong to the current selectable fragment. In this case + // we should invalidate the text boundary so that it is not taken into account when + // computing the target position. + textBoundary = null; + } + final targetPosition = _clampTextPosition( + isEnd + ? _updateSelectionEndEdgeByTextBoundary( + textBoundary, + getClampedTextBoundary, + position, + existingSelectionStart, + existingSelectionEnd, + ) + : _updateSelectionStartEdgeByTextBoundary( + textBoundary, + getClampedTextBoundary, + position, + existingSelectionStart, + existingSelectionEnd, + ), + ); + + _setSelectionPosition(targetPosition, isEnd: isEnd); + if (targetPosition.offset == range.end) { + return SelectionResult.next; + } + + if (targetPosition.offset == range.start) { + return SelectionResult.previous; + } + // TODO(chunhtai): The geometry information should not be used to determine + // selection result. This is a workaround to RenderParagraph, where it does + // not have a way to get accurate text length if its text is truncated due to + // layout constraint. + return SelectionUtils.getResultBasedOnRect(_rect, localPosition); + } + + TextPosition _closestTextBoundary( + _TextBoundaryRecord textBoundary, + TextPosition position, + ) { + final differenceA = + (position.offset - textBoundary.boundaryStart.offset).abs(); + final differenceB = + (position.offset - textBoundary.boundaryEnd.offset).abs(); + return differenceA < differenceB + ? textBoundary.boundaryStart + : textBoundary.boundaryEnd; + } + + bool _isPlaceholder() { + // Determine whether this selectable fragment is a placeholder. + var current = paragraph.parent; + while (current != null) { + if (current is TRenderParagraph) { + return true; + } + current = current.parent; + } + return false; + } + + TRenderParagraph _getOriginParagraph() { + // This method should only be called from a fragment that contains + // the origin boundary. By traversing up the RenderTree, determine the + // highest RenderParagraph that contains the origin text boundary. + assert(_selectableContainsOriginTextBoundary); + // Begin at the parent because it is guaranteed the paragraph containing + // this selectable fragment contains the origin boundary. + var current = paragraph.parent; + TRenderParagraph? originParagraph; + while (current != null) { + if (current is TRenderParagraph) { + if (current._lastSelectableFragments != null) { + var paragraphContainsOriginTextBoundary = false; + for (final fragment in current._lastSelectableFragments!) { + if (fragment._selectableContainsOriginTextBoundary) { + paragraphContainsOriginTextBoundary = true; + originParagraph = current; + break; + } + } + if (!paragraphContainsOriginTextBoundary) { + return originParagraph ?? paragraph; + } + } + } + current = current.parent; + } + return originParagraph ?? paragraph; + } + + ({TRenderParagraph paragraph, Offset localPosition})? + _getParagraphContainingPosition(Offset globalPosition) { + // This method will return the closest [RenderParagraph] whose rect + // contains the given `globalPosition` and the given `globalPosition` + // relative to that [RenderParagraph]. If no ancestor [RenderParagraph] + // contains the given `globalPosition` then this method will return null. + RenderObject? current = paragraph; + while (current != null) { + if (current is TRenderParagraph) { + final currentTransform = current.getTransformTo(null); + currentTransform.invert(); + final currentParagraphLocalPosition = + MatrixUtils.transformPoint(currentTransform, globalPosition); + final positionWithinCurrentParagraph = + current.paintBounds.contains(currentParagraphLocalPosition); + if (positionWithinCurrentParagraph) { + return ( + paragraph: current, + localPosition: currentParagraphLocalPosition + ); + } + } + current = current.parent; + } + return null; + } + + bool _boundingBoxesContains(Offset position) { + for (final rect in boundingBoxes) { + if (rect.contains(position)) { + return true; + } + } + return false; + } + + TextPosition _clampTextPosition(TextPosition position) { + // Affinity of range.end is upstream. + if (position.offset > range.end || + (position.offset == range.end && + position.affinity == TextAffinity.downstream)) { + return TextPosition(offset: range.end, affinity: TextAffinity.upstream); + } + if (position.offset < range.start) { + return TextPosition(offset: range.start); + } + return position; + } + + void _setSelectionPosition(TextPosition? position, {required bool isEnd}) { + if (isEnd) { + _textSelectionEnd = position; + } else { + _textSelectionStart = position; + } + } + + SelectionResult _handleClearSelection() { + _textSelectionStart = null; + _textSelectionEnd = null; + _selectableContainsOriginTextBoundary = false; + return SelectionResult.none; + } + + SelectionResult _handleSelectAll() { + _textSelectionStart = TextPosition(offset: range.start); + _textSelectionEnd = + TextPosition(offset: range.end, affinity: TextAffinity.upstream); + return SelectionResult.none; + } + + SelectionResult _handleSelectTextBoundary(_TextBoundaryRecord textBoundary) { + // This fragment may not contain the boundary, decide what direction the target + // fragment is located in. Because fragments are separated by placeholder + // spans, we also check if the beginning or end of the boundary is touching + // either edge of this fragment. + if (textBoundary.boundaryStart.offset < range.start && + textBoundary.boundaryEnd.offset <= range.start) { + return SelectionResult.previous; + } else if (textBoundary.boundaryStart.offset >= range.end && + textBoundary.boundaryEnd.offset > range.end) { + return SelectionResult.next; + } + // Fragments are separated by placeholder span, the text boundary shouldn't + // expand across fragments. + assert(textBoundary.boundaryStart.offset >= range.start && + textBoundary.boundaryEnd.offset <= range.end); + _textSelectionStart = textBoundary.boundaryStart; + _textSelectionEnd = textBoundary.boundaryEnd; + _selectableContainsOriginTextBoundary = true; + return SelectionResult.end; + } + + TextRange? _intersect(TextRange a, TextRange b) { + assert(a.isNormalized); + assert(b.isNormalized); + final int startMax = math.max(a.start, b.start); + final int endMin = math.min(a.end, b.end); + if (startMax <= endMin) { + // Intersection. + return TextRange(start: startMax, end: endMin); + } + return null; + } + + SelectionResult _handleSelectMultiFragmentTextBoundary( + _TextBoundaryRecord textBoundary) { + // This fragment may not contain the boundary, decide what direction the target + // fragment is located in. Because fragments are separated by placeholder + // spans, we also check if the beginning or end of the boundary is touching + // either edge of this fragment. + if (textBoundary.boundaryStart.offset < range.start && + textBoundary.boundaryEnd.offset <= range.start) { + return SelectionResult.previous; + } else if (textBoundary.boundaryStart.offset >= range.end && + textBoundary.boundaryEnd.offset > range.end) { + return SelectionResult.next; + } + final boundaryAsRange = TextRange( + start: textBoundary.boundaryStart.offset, + end: textBoundary.boundaryEnd.offset); + final intersectRange = _intersect(range, boundaryAsRange); + if (intersectRange != null) { + _textSelectionStart = TextPosition(offset: intersectRange.start); + _textSelectionEnd = TextPosition(offset: intersectRange.end); + _selectableContainsOriginTextBoundary = true; + if (range.end < textBoundary.boundaryEnd.offset) { + return SelectionResult.next; + } + return SelectionResult.end; + } + return SelectionResult.none; + } + + _TextBoundaryRecord _adjustTextBoundaryAtPosition( + TextRange textBoundary, TextPosition position) { + late final TextPosition start; + late final TextPosition end; + if (position.offset > textBoundary.end) { + start = end = TextPosition(offset: position.offset); + } else { + start = TextPosition(offset: textBoundary.start); + end = TextPosition( + offset: textBoundary.end, affinity: TextAffinity.upstream); + } + return (boundaryStart: start, boundaryEnd: end); + } + + SelectionResult _handleSelectWord(Offset globalPosition) { + final position = + paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition)); + if (_positionIsWithinCurrentSelection(position) && + _textSelectionStart != _textSelectionEnd) { + return SelectionResult.end; + } + final wordBoundary = _getWordBoundaryAtPosition(position); + return _handleSelectTextBoundary(wordBoundary); + } + + _TextBoundaryRecord _getWordBoundaryAtPosition(TextPosition position) { + final word = paragraph.getWordBoundary(position); + assert(word.isNormalized); + return _adjustTextBoundaryAtPosition(word, position); + } + + SelectionResult _handleSelectParagraph(Offset globalPosition) { + final localPosition = paragraph.globalToLocal(globalPosition); + final position = paragraph.getPositionForOffset(localPosition); + final paragraphBoundary = + _getParagraphBoundaryAtPosition(position, fullText); + return _handleSelectMultiFragmentTextBoundary(paragraphBoundary); + } + + TextPosition _getPositionInParagraph(TRenderParagraph targetParagraph) { + final transform = paragraph.getTransformTo(targetParagraph); + final localCenter = paragraph.paintBounds.centerLeft; + final localPos = MatrixUtils.transformPoint(transform, localCenter); + final position = targetParagraph.getPositionForOffset(localPos); + return position; + } + + _TextBoundaryRecord _getParagraphBoundaryAtPosition( + TextPosition position, String text) { + final paragraphBoundary = ParagraphBoundary(text); + // Use position.offset - 1 when `position` is at the end of the selectable to retrieve + // the previous text boundary's location. + final paragraphStart = paragraphBoundary.getLeadingTextBoundaryAt( + position.offset == text.length || + position.affinity == TextAffinity.upstream + ? position.offset - 1 + : position.offset) ?? + 0; + final paragraphEnd = + paragraphBoundary.getTrailingTextBoundaryAt(position.offset) ?? + text.length; + final paragraphRange = TextRange(start: paragraphStart, end: paragraphEnd); + assert(paragraphRange.isNormalized); + return _adjustTextBoundaryAtPosition(paragraphRange, position); + } + + _TextBoundaryRecord _getClampedParagraphBoundaryAtPosition( + TextPosition position) { + final paragraphBoundary = ParagraphBoundary(fullText); + // Use position.offset - 1 when `position` is at the end of the selectable to retrieve + // the previous text boundary's location. + var paragraphStart = paragraphBoundary.getLeadingTextBoundaryAt( + position.offset == fullText.length || + position.affinity == TextAffinity.upstream + ? position.offset - 1 + : position.offset) ?? + 0; + var paragraphEnd = + paragraphBoundary.getTrailingTextBoundaryAt(position.offset) ?? + fullText.length; + paragraphStart = paragraphStart < range.start + ? range.start + : paragraphStart > range.end + ? range.end + : paragraphStart; + paragraphEnd = paragraphEnd > range.end + ? range.end + : paragraphEnd < range.start + ? range.start + : paragraphEnd; + final paragraphRange = TextRange(start: paragraphStart, end: paragraphEnd); + assert(paragraphRange.isNormalized); + return _adjustTextBoundaryAtPosition(paragraphRange, position); + } + + SelectionResult _handleDirectionallyExtendSelection(double horizontalBaseline, + bool isExtent, SelectionExtendDirection movement) { + final transform = paragraph.getTransformTo(null); + if (transform.invert() == 0.0) { + switch (movement) { + case SelectionExtendDirection.previousLine: + case SelectionExtendDirection.backward: + return SelectionResult.previous; + case SelectionExtendDirection.nextLine: + case SelectionExtendDirection.forward: + return SelectionResult.next; + } + } + final baselineInParagraphCoordinates = + MatrixUtils.transformPoint(transform, Offset(horizontalBaseline, 0)).dx; + assert(!baselineInParagraphCoordinates.isNaN); + final TextPosition newPosition; + final SelectionResult result; + switch (movement) { + case SelectionExtendDirection.previousLine: + case SelectionExtendDirection.nextLine: + assert(_textSelectionEnd != null && _textSelectionStart != null); + final targetedEdge = + isExtent ? _textSelectionEnd! : _textSelectionStart!; + final moveResult = _handleVerticalMovement( + targetedEdge, + horizontalBaselineInParagraphCoordinates: + baselineInParagraphCoordinates, + below: movement == SelectionExtendDirection.nextLine, + ); + newPosition = moveResult.key; + result = moveResult.value; + case SelectionExtendDirection.forward: + case SelectionExtendDirection.backward: + _textSelectionEnd ??= movement == SelectionExtendDirection.forward + ? TextPosition(offset: range.start) + : TextPosition(offset: range.end, affinity: TextAffinity.upstream); + _textSelectionStart ??= _textSelectionEnd; + final targetedEdge = + isExtent ? _textSelectionEnd! : _textSelectionStart!; + final edgeOffsetInParagraphCoordinates = + paragraph._getOffsetForPosition(targetedEdge); + final baselineOffsetInParagraphCoordinates = Offset( + baselineInParagraphCoordinates, + // Use half of line height to point to the middle of the line. + edgeOffsetInParagraphCoordinates.dy - + paragraph._textPainter.preferredLineHeight / 2, + ); + newPosition = paragraph + .getPositionForOffset(baselineOffsetInParagraphCoordinates); + result = SelectionResult.end; + } + if (isExtent) { + _textSelectionEnd = newPosition; + } else { + _textSelectionStart = newPosition; + } + return result; + } + + SelectionResult _handleGranularlyExtendSelection( + bool forward, bool isExtent, TextGranularity granularity) { + _textSelectionEnd ??= forward + ? TextPosition(offset: range.start) + : TextPosition(offset: range.end, affinity: TextAffinity.upstream); + _textSelectionStart ??= _textSelectionEnd; + final targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!; + if (forward && (targetedEdge.offset == range.end)) { + return SelectionResult.next; + } + if (!forward && (targetedEdge.offset == range.start)) { + return SelectionResult.previous; + } + final SelectionResult result; + final TextPosition newPosition; + switch (granularity) { + case TextGranularity.character: + final text = range.textInside(fullText); + newPosition = _moveBeyondTextBoundaryAtDirection( + targetedEdge, forward, CharacterBoundary(text)); + result = SelectionResult.end; + case TextGranularity.word: + final textBoundary = + paragraph._textPainter.wordBoundaries.moveByWordBoundary; + newPosition = _moveBeyondTextBoundaryAtDirection( + targetedEdge, forward, textBoundary); + result = SelectionResult.end; + case TextGranularity.paragraph: + final text = range.textInside(fullText); + newPosition = _moveBeyondTextBoundaryAtDirection( + targetedEdge, forward, ParagraphBoundary(text)); + result = SelectionResult.end; + case TextGranularity.line: + newPosition = _moveToTextBoundaryAtDirection( + targetedEdge, forward, LineBoundary(this)); + result = SelectionResult.end; + case TextGranularity.document: + final text = range.textInside(fullText); + newPosition = _moveBeyondTextBoundaryAtDirection( + targetedEdge, forward, DocumentBoundary(text)); + if (forward && newPosition.offset == range.end) { + result = SelectionResult.next; + } else if (!forward && newPosition.offset == range.start) { + result = SelectionResult.previous; + } else { + result = SelectionResult.end; + } + } + + if (isExtent) { + _textSelectionEnd = newPosition; + } else { + _textSelectionStart = newPosition; + } + return result; + } + + // Move **beyond** the local boundary of the given type (unless range.start or + // range.end is reached). Used for most TextGranularity types except for + // TextGranularity.line, to ensure the selection movement doesn't get stuck at + // a local fixed point. + TextPosition _moveBeyondTextBoundaryAtDirection( + TextPosition end, bool forward, TextBoundary textBoundary) { + final newOffset = forward + ? textBoundary.getTrailingTextBoundaryAt(end.offset) ?? range.end + : textBoundary.getLeadingTextBoundaryAt(end.offset - 1) ?? range.start; + return TextPosition(offset: newOffset); + } + + // Move **to** the local boundary of the given type. Typically used for line + // boundaries, such that performing "move to line start" more than once never + // moves the selection to the previous line. + TextPosition _moveToTextBoundaryAtDirection( + TextPosition end, bool forward, TextBoundary textBoundary) { + assert(end.offset >= 0); + final int caretOffset; + switch (end.affinity) { + case TextAffinity.upstream: + if (end.offset < 1 && !forward) { + assert(end.offset == 0); + return const TextPosition(offset: 0); + } + final characterBoundary = CharacterBoundary(fullText); + caretOffset = math.max( + 0, + characterBoundary + .getLeadingTextBoundaryAt(range.start + end.offset) ?? + range.start, + ) - + 1; + case TextAffinity.downstream: + caretOffset = end.offset; + } + final offset = forward + ? textBoundary.getTrailingTextBoundaryAt(caretOffset) ?? range.end + : textBoundary.getLeadingTextBoundaryAt(caretOffset) ?? range.start; + return TextPosition(offset: offset); + } + + MapEntry _handleVerticalMovement( + TextPosition position, + {required double horizontalBaselineInParagraphCoordinates, + required bool below}) { + final lines = paragraph._textPainter.computeLineMetrics(); + final offset = paragraph.getOffsetForCaret(position, Rect.zero); + var currentLine = lines.length - 1; + for (final lineMetrics in lines) { + if (lineMetrics.baseline > offset.dy) { + currentLine = lineMetrics.lineNumber; + break; + } + } + final TextPosition newPosition; + if (below && currentLine == lines.length - 1) { + newPosition = + TextPosition(offset: range.end, affinity: TextAffinity.upstream); + } else if (!below && currentLine == 0) { + newPosition = TextPosition(offset: range.start); + } else { + final newLine = below ? currentLine + 1 : currentLine - 1; + newPosition = _clampTextPosition(paragraph.getPositionForOffset(Offset( + horizontalBaselineInParagraphCoordinates, lines[newLine].baseline))); + } + final SelectionResult result; + if (newPosition.offset == range.start) { + result = SelectionResult.previous; + } else if (newPosition.offset == range.end) { + result = SelectionResult.next; + } else { + result = SelectionResult.end; + } + assert(result != SelectionResult.next || below); + assert(result != SelectionResult.previous || !below); + return MapEntry(newPosition, result); + } + + /// Whether the given text position is contained in current selection + /// range. + /// + /// The parameter `start` must be smaller than `end`. + bool _positionIsWithinCurrentSelection(TextPosition position) { + if (_textSelectionStart == null || _textSelectionEnd == null) { + return false; + } + // Normalize current selection. + late TextPosition currentStart; + late TextPosition currentEnd; + if (_compareTextPositions(_textSelectionStart!, _textSelectionEnd!) > 0) { + currentStart = _textSelectionStart!; + currentEnd = _textSelectionEnd!; + } else { + currentStart = _textSelectionEnd!; + currentEnd = _textSelectionStart!; + } + return _compareTextPositions(currentStart, position) >= 0 && + _compareTextPositions(currentEnd, position) <= 0; + } + + /// Compares two text positions. + /// + /// Returns 1 if `position` < `otherPosition`, -1 if `position` > `otherPosition`, + /// or 0 if they are equal. + static int _compareTextPositions( + TextPosition position, TextPosition otherPosition) { + if (position.offset < otherPosition.offset) { + return 1; + } else if (position.offset > otherPosition.offset) { + return -1; + } else if (position.affinity == otherPosition.affinity) { + return 0; + } else { + return position.affinity == TextAffinity.upstream ? 1 : -1; + } + } + + @override + Matrix4 getTransformTo(RenderObject? ancestor) => + paragraph.getTransformTo(ancestor); + + @override + void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { + if (!paragraph.attached) { + assert(startHandle == null && endHandle == null, + 'Only clean up can be called.'); + return; + } + if (_startHandleLayerLink != startHandle) { + _startHandleLayerLink = startHandle; + paragraph.markNeedsPaint(); + } + if (_endHandleLayerLink != endHandle) { + _endHandleLayerLink = endHandle; + paragraph.markNeedsPaint(); + } + } + + List? _cachedBoundingBoxes; + + @override + List get boundingBoxes { + if (_cachedBoundingBoxes == null) { + final boxes = paragraph.getBoxesForSelection( + TextSelection(baseOffset: range.start, extentOffset: range.end), + ); + if (boxes.isNotEmpty) { + _cachedBoundingBoxes = []; + for (final textBox in boxes) { + _cachedBoundingBoxes!.add(textBox.toRect()); + } + } else { + final offset = + paragraph._getOffsetForPosition(TextPosition(offset: range.start)); + final rect = Rect.fromPoints(offset, + offset.translate(0, -paragraph._textPainter.preferredLineHeight)); + _cachedBoundingBoxes = [rect]; + } + } + return _cachedBoundingBoxes!; + } + + Rect? _cachedRect; + + Rect get _rect { + if (_cachedRect == null) { + final boxes = paragraph.getBoxesForSelection( + TextSelection(baseOffset: range.start, extentOffset: range.end), + ); + if (boxes.isNotEmpty) { + var result = boxes.first.toRect(); + for (var index = 1; index < boxes.length; index += 1) { + result = result.expandToInclude(boxes[index].toRect()); + } + _cachedRect = result; + } else { + final offset = + paragraph._getOffsetForPosition(TextPosition(offset: range.start)); + _cachedRect = Rect.fromPoints(offset, + offset.translate(0, -paragraph._textPainter.preferredLineHeight)); + } + } + return _cachedRect!; + } + + void didChangeParagraphLayout() { + _cachedRect = null; + _cachedBoundingBoxes = null; + } + + @override + Size get size => _rect.size; + + void paint(PaintingContext context, Offset offset) { + if (_textSelectionStart == null || _textSelectionEnd == null) { + return; + } + if (paragraph.selectionColor != null) { + final selection = TextSelection( + baseOffset: _textSelectionStart!.offset, + extentOffset: _textSelectionEnd!.offset, + ); + final selectionPaint = Paint() + ..style = PaintingStyle.fill + ..color = paragraph.selectionColor!; + for (final textBox in paragraph.getBoxesForSelection(selection)) { + context.canvas.drawRect(textBox.toRect().shift(offset), selectionPaint); + } + } + if (_startHandleLayerLink != null && value.startSelectionPoint != null) { + context.pushLayer( + LeaderLayer( + link: _startHandleLayerLink!, + offset: offset + value.startSelectionPoint!.localPosition, + ), + (PaintingContext context, Offset offset) {}, + Offset.zero, + ); + } + if (_endHandleLayerLink != null && value.endSelectionPoint != null) { + context.pushLayer( + LeaderLayer( + link: _endHandleLayerLink!, + offset: offset + value.endSelectionPoint!.localPosition, + ), + (PaintingContext context, Offset offset) {}, + Offset.zero, + ); + } + } + + @override + TextSelection getLineAtOffset(TextPosition position) { + final line = paragraph._getLineAtOffset(position); + final start = line.start.clamp(range.start, range.end); + final end = line.end.clamp(range.start, range.end); + return TextSelection(baseOffset: start, extentOffset: end); + } + + @override + TextPosition getTextPositionAbove(TextPosition position) => + _clampTextPosition(paragraph._getTextPositionAbove(position)); + + @override + TextPosition getTextPositionBelow(TextPosition position) => + _clampTextPosition(paragraph._getTextPositionBelow(position)); + + @override + TextRange getWordBoundary(TextPosition position) => + paragraph.getWordBoundary(position); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty( + 'textInsideRange', range.textInside(fullText))); + properties.add(DiagnosticsProperty('range', range)); + properties.add(DiagnosticsProperty('fullText', fullText)); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_text/t_text.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_text/t_text.dart new file mode 100644 index 0000000000..d9bdb9e29e --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_text/t_text.dart @@ -0,0 +1,1511 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'package:flutter/gestures.dart'; +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'editable_text.dart'; +/// @docImport 'gesture_detector.dart'; +/// @docImport 'implicit_animations.dart'; +/// @docImport 'transitions.dart'; +/// @docImport 'widget_span.dart'; + +import 'dart:math'; +import 'dart:ui' as ui show TextHeightBehavior; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import 't_paragraph.dart'; + +// Examples can assume: +// late String _name; +// late BuildContext context; + +class _NullWidget extends StatelessWidget { + const _NullWidget(); + + @override + Widget build(BuildContext context) { + throw FlutterError( + 'A DefaultTextStyle constructed with DefaultTextStyle.fallback cannot be incorporated into the widget tree, ' + 'it is meant only to provide a fallback value returned by DefaultTextStyle.of() ' + 'when no enclosing default text style is present in a BuildContext.', + ); + } +} + +/// A run of text with a single style. +/// +/// The [TText] widget displays a string of text with single style. The string +/// might break across multiple lines or might all be displayed on the same line +/// depending on the layout constraints. +/// +/// The [style] argument is optional. When omitted, the text will use the style +/// from the closest enclosing [DefaultTextStyle]. If the given style's +/// [TextStyle.inherit] property is true (the default), the given style will +/// be merged with the closest enclosing [DefaultTextStyle]. This merging +/// behavior is useful, for example, to make the text bold while using the +/// default font family and size. +/// +/// {@tool snippet} +/// +/// This example shows how to display text using the [TText] widget with the +/// [overflow] set to [TextOverflow.ellipsis]. +/// +/// ![If the text overflows, the Text widget displays an ellipsis to trim the overflowing text](https://flutter.github.io/assets-for-api-docs/assets/widgets/text_ellipsis.png) +/// +/// ```dart +/// Container( +/// width: 100, +/// decoration: BoxDecoration(border: Border.all()), +/// child: Text(overflow: TextOverflow.ellipsis, 'Hello $_name, how are you?')) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Setting [maxLines] to `1` is not equivalent to disabling soft wrapping with +/// [softWrap]. This is apparent when using [TextOverflow.fade] as the following +/// examples show. +/// +/// ![If a second line overflows the Text widget displays a horizontal fade](https://flutter.github.io/assets-for-api-docs/assets/widgets/text_fade_max_lines.png) +/// +/// ```dart +/// Text( +/// overflow: TextOverflow.fade, +/// maxLines: 1, +/// 'Hello $_name, how are you?') +/// ``` +/// +/// Here soft wrapping is enabled and the [TText] widget tries to wrap the words +/// "how are you?" to a second line. This is prevented by the [maxLines] value +/// of `1`. The result is that a second line overflows and the fade appears in a +/// horizontal direction at the bottom. +/// +/// ![If a single line overflows the Text widget displays a horizontal fade](https://flutter.github.io/assets-for-api-docs/assets/widgets/text_fade_soft_wrap.png) +/// +/// ```dart +/// Text( +/// overflow: TextOverflow.fade, +/// softWrap: false, +/// 'Hello $_name, how are you?') +/// ``` +/// +/// Here soft wrapping is disabled with `softWrap: false` and the [TText] widget +/// attempts to display its text in a single unbroken line. The result is that +/// the single line overflows and the fade appears in a vertical direction at +/// the right. +/// +/// {@end-tool} +/// +/// Using the [Text.rich] constructor, the [TText] widget can +/// display a paragraph with differently styled [TextSpan]s. The sample +/// that follows displays "Hello beautiful world" with different styles +/// for each word. +/// +/// {@tool snippet} +/// +/// ![The word "Hello" is shown with the default text styles. The word "beautiful" is italicized. The word "world" is bold.](https://flutter.github.io/assets-for-api-docs/assets/widgets/text_rich.png) +/// +/// ```dart +/// const Text.rich( +/// TextSpan( +/// text: 'Hello', // default text style +/// children: [ +/// TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)), +/// TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Interactivity +/// +/// To make [TText] react to touch events, wrap it in a [GestureDetector] widget +/// with a [GestureDetector.onTap] handler. +/// +/// In a Material Design application, consider using a [TextButton] instead, or +/// if that isn't appropriate, at least using an [InkWell] instead of +/// [GestureDetector]. +/// +/// To make sections of the text interactive, use [RichText] and specify a +/// [TapGestureRecognizer] as the [TextSpan.recognizer] of the relevant part of +/// the text. +/// +/// ## Selection +/// +/// [TText] is not selectable by default. To make a [TText] selectable, one can +/// wrap a subtree with a [SelectionArea] widget. To exclude a part of a subtree +/// under [SelectionArea] from selection, once can also wrap that part of the +/// subtree with [SelectionContainer.disabled]. +/// +/// {@tool dartpad} +/// This sample demonstrates how to disable selection for a Text under a +/// SelectionArea. +/// +/// ** See code in examples/api/lib/material/selection_container/selection_container_disabled.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [RichText], which gives you more control over the text styles. +/// * [DefaultTextStyle], which sets default styles for [TText] widgets. +/// * [SelectableRegion], which provides an overview of the selection system. +class TText extends StatelessWidget { + /// Creates a text widget. + /// + /// If the [style] argument is null, the text will use the style from the + /// closest enclosing [DefaultTextStyle]. + /// + /// The [overflow] property's behavior is affected by the [softWrap] argument. + /// If the [softWrap] is true or null, the glyph causing overflow, and those + /// that follow, will not be rendered. Otherwise, it will be shown with the + /// given overflow option. + const TText( + String this.data, { + super.key, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + this.locale, + this.softWrap, + this.overflow, + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + this.textScaleFactor, + this.textScaler, + this.maxLines, + this.semanticsLabel, + this.textWidthBasis, + this.textHeightBehavior, + this.selectionColor, + }) : textSpan = null, + assert( + textScaler == null || textScaleFactor == null, + 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', + ); + + /// Creates a text widget with a [InlineSpan]. + /// + /// The following subclasses of [InlineSpan] may be used to build rich text: + /// + /// * [TextSpan]s define text and children [InlineSpan]s. + /// * [WidgetSpan]s define embedded inline widgets. + /// + /// See [RichText] which provides a lower-level way to draw text. + const TText.rich( + InlineSpan this.textSpan, { + super.key, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + this.locale, + this.softWrap, + this.overflow, + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + this.textScaleFactor, + this.textScaler, + this.maxLines, + this.semanticsLabel, + this.textWidthBasis, + this.textHeightBehavior, + this.selectionColor, + }) : data = null, + assert( + textScaler == null || textScaleFactor == null, + 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', + ); + + /// The text to display. + /// + /// This will be null if a [textSpan] is provided instead. + final String? data; + + /// The text to display as a [InlineSpan]. + /// + /// This will be null if [data] is provided instead. + final InlineSpan? textSpan; + + /// If non-null, the style to use for this text. + /// + /// If the style's "inherit" property is true, the style will be merged with + /// the closest enclosing [DefaultTextStyle]. Otherwise, the style will + /// replace the closest enclosing [DefaultTextStyle]. + final TextStyle? style; + + /// {@macro flutter.painting.textPainter.strutStyle} + final StrutStyle? strutStyle; + + /// How the text should be aligned horizontally. + final TextAlign? textAlign; + + /// The directionality of the text. + /// + /// This decides how [textAlign] values like [TextAlign.start] and + /// [TextAlign.end] are interpreted. + /// + /// This is also used to disambiguate how to render bidirectional text. For + /// example, if the [data] is an English phrase followed by a Hebrew phrase, + /// in a [TextDirection.ltr] context the English phrase will be on the left + /// and the Hebrew phrase to its right, while in a [TextDirection.rtl] + /// context, the English phrase will be on the right and the Hebrew phrase on + /// its left. + /// + /// Defaults to the ambient [Directionality], if any. + final TextDirection? textDirection; + + /// Used to select a font when the same Unicode character can + /// be rendered differently, depending on the locale. + /// + /// It's rarely necessary to set this property. By default its value + /// is inherited from the enclosing app with `Localizations.localeOf(context)`. + /// + /// See [RenderParagraph.locale] for more information. + final Locale? locale; + + /// Whether the text should break at soft line breaks. + /// + /// If false, the glyphs in the text will be positioned as if there was unlimited horizontal space. + final bool? softWrap; + + /// How visual overflow should be handled. + /// + /// If this is null [TextStyle.overflow] will be used, otherwise the value + /// from the nearest [DefaultTextStyle] ancestor will be used. + final TextOverflow? overflow; + + /// Deprecated. Will be removed in a future version of Flutter. Use + /// [textScaler] instead. + /// + /// The number of font pixels for each logical pixel. + /// + /// For example, if the text scale factor is 1.5, text will be 50% larger than + /// the specified font size. + /// + /// The value given to the constructor as textScaleFactor. If null, will + /// use the [MediaQueryData.textScaleFactor] obtained from the ambient + /// [MediaQuery], or 1.0 if there is no [MediaQuery] in scope. + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + final double? textScaleFactor; + + /// {@macro flutter.painting.textPainter.textScaler} + final TextScaler? textScaler; + + /// An optional maximum number of lines for the text to span, wrapping if necessary. + /// If the text exceeds the given number of lines, it will be truncated according + /// to [overflow]. + /// + /// If this is 1, text will not wrap. Otherwise, text will be wrapped at the + /// edge of the box. + /// + /// If this is null, but there is an ambient [DefaultTextStyle] that specifies + /// an explicit number for its [DefaultTextStyle.maxLines], then the + /// [DefaultTextStyle] value will take precedence. You can use a [RichText] + /// widget directly to entirely override the [DefaultTextStyle]. + final int? maxLines; + + /// {@template flutter.widgets.Text.semanticsLabel} + /// An alternative semantics label for this text. + /// + /// If present, the semantics of this widget will contain this value instead + /// of the actual text. This will overwrite any of the semantics labels applied + /// directly to the [TextSpan]s. + /// + /// This is useful for replacing abbreviations or shorthands with the full + /// text value: + /// + /// ```dart + /// const Text(r'$$', semanticsLabel: 'Double dollars') + /// ``` + /// {@endtemplate} + final String? semanticsLabel; + + /// {@macro flutter.painting.textPainter.textWidthBasis} + final TextWidthBasis? textWidthBasis; + + /// {@macro dart.ui.textHeightBehavior} + final ui.TextHeightBehavior? textHeightBehavior; + + /// The color to use when painting the selection. + /// + /// This is ignored if [SelectionContainer.maybeOf] returns null + /// in the [BuildContext] of the [TText] widget. + /// + /// If null, the ambient [DefaultSelectionStyle] is used (if any); failing + /// that, the selection color defaults to [DefaultSelectionStyle.defaultColor] + /// (semi-transparent grey). + final Color? selectionColor; + + @override + Widget build(BuildContext context) { + final defaultTextStyle = DefaultTextStyle.of(context); + var effectiveTextStyle = style; + if (style == null || style!.inherit) { + effectiveTextStyle = defaultTextStyle.style.merge(style); + } + if (MediaQuery.boldTextOf(context)) { + effectiveTextStyle = effectiveTextStyle! + .merge(const TextStyle(fontWeight: FontWeight.bold)); + } + final registrar = SelectionContainer.maybeOf(context); + final textScaler = switch ((this.textScaler, textScaleFactor)) { + (final TextScaler textScaler, _) => textScaler, + // For unmigrated apps, fall back to textScaleFactor. + (null, final double textScaleFactor) => + TextScaler.linear(textScaleFactor), + (null, null) => MediaQuery.textScalerOf(context), + }; + late Widget result; + if (registrar != null) { + result = MouseRegion( + cursor: DefaultSelectionStyle.of(context).mouseCursor ?? + SystemMouseCursors.text, + child: _SelectableTextContainer( + textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start, + textDirection: textDirection, + // RichText uses Directionality.of to obtain a default if this is null. + locale: locale, + // RichText uses Localizations.localeOf to obtain a default if this is null + softWrap: softWrap ?? defaultTextStyle.softWrap, + overflow: overflow ?? + effectiveTextStyle?.overflow ?? + defaultTextStyle.overflow, + textScaler: textScaler, + maxLines: maxLines ?? defaultTextStyle.maxLines, + strutStyle: strutStyle, + textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis, + textHeightBehavior: textHeightBehavior ?? + defaultTextStyle.textHeightBehavior ?? + DefaultTextHeightBehavior.maybeOf(context), + selectionColor: selectionColor ?? + DefaultSelectionStyle.of(context).selectionColor ?? + DefaultSelectionStyle.defaultColor, + text: TextSpan( + style: effectiveTextStyle, + text: data, + children: textSpan != null ? [textSpan!] : null, + ), + ), + ); + } else { + result = RichText( + textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start, + textDirection: textDirection, + // RichText uses Directionality.of to obtain a default if this is null. + locale: locale, + // RichText uses Localizations.localeOf to obtain a default if this is null + softWrap: softWrap ?? defaultTextStyle.softWrap, + overflow: overflow ?? + effectiveTextStyle?.overflow ?? + defaultTextStyle.overflow, + textScaler: textScaler, + maxLines: maxLines ?? defaultTextStyle.maxLines, + strutStyle: strutStyle, + textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis, + textHeightBehavior: textHeightBehavior ?? + defaultTextStyle.textHeightBehavior ?? + DefaultTextHeightBehavior.maybeOf(context), + selectionColor: selectionColor ?? + DefaultSelectionStyle.of(context).selectionColor ?? + DefaultSelectionStyle.defaultColor, + text: TextSpan( + style: effectiveTextStyle, + text: data, + children: textSpan != null ? [textSpan!] : null, + ), + ); + } + if (semanticsLabel != null) { + result = Semantics( + textDirection: textDirection, + label: semanticsLabel, + child: ExcludeSemantics( + child: result, + ), + ); + } + return result; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('data', data, showName: false)); + if (textSpan != null) { + properties.add(textSpan!.toDiagnosticsNode( + name: 'textSpan', style: DiagnosticsTreeStyle.transition)); + } + style?.debugFillProperties(properties); + properties.add( + EnumProperty('textAlign', textAlign, defaultValue: null)); + properties.add(EnumProperty('textDirection', textDirection, + defaultValue: null)); + properties + .add(DiagnosticsProperty('locale', locale, defaultValue: null)); + properties.add(FlagProperty('softWrap', + value: softWrap, + ifTrue: 'wrapping at box width', + ifFalse: 'no wrapping except at line break characters', + showName: true)); + properties.add( + EnumProperty('overflow', overflow, defaultValue: null)); + properties.add( + DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null)); + properties.add(IntProperty('maxLines', maxLines, defaultValue: null)); + properties.add(EnumProperty( + 'textWidthBasis', textWidthBasis, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'textHeightBehavior', textHeightBehavior, + defaultValue: null)); + if (semanticsLabel != null) { + properties.add(StringProperty('semanticsLabel', semanticsLabel)); + } + } +} + +class _SelectableTextContainer extends StatefulWidget { + const _SelectableTextContainer({ + required this.text, + required this.textAlign, + this.textDirection, + required this.softWrap, + required this.overflow, + required this.textScaler, + this.maxLines, + this.locale, + this.strutStyle, + required this.textWidthBasis, + this.textHeightBehavior, + required this.selectionColor, + }); + + final InlineSpan text; + final TextAlign textAlign; + final TextDirection? textDirection; + final bool softWrap; + final TextOverflow overflow; + final TextScaler textScaler; + final int? maxLines; + final Locale? locale; + final StrutStyle? strutStyle; + final TextWidthBasis textWidthBasis; + final ui.TextHeightBehavior? textHeightBehavior; + final Color selectionColor; + + @override + State<_SelectableTextContainer> createState() => + _SelectableTextContainerState(); +} + +class _SelectableTextContainerState extends State<_SelectableTextContainer> { + late final _SelectableTextContainerDelegate _selectionDelegate; + final GlobalKey _textKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _selectionDelegate = _SelectableTextContainerDelegate(_textKey); + } + + @override + void dispose() { + _selectionDelegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => SelectionContainer( + delegate: _selectionDelegate, + // Use [_RichText] wrapper so the underlying [RenderParagraph] can register + // its [Selectable]s to the [SelectionContainer] created by this widget. + child: _RichText( + textKey: _textKey, + textAlign: widget.textAlign, + textDirection: widget.textDirection, + locale: widget.locale, + softWrap: widget.softWrap, + overflow: widget.overflow, + textScaler: widget.textScaler, + maxLines: widget.maxLines, + strutStyle: widget.strutStyle, + textWidthBasis: widget.textWidthBasis, + textHeightBehavior: widget.textHeightBehavior, + selectionColor: widget.selectionColor, + text: widget.text, + ), + ); +} + +class _RichText extends StatelessWidget { + const _RichText({ + this.textKey, + required this.text, + required this.textAlign, + this.textDirection, + required this.softWrap, + required this.overflow, + required this.textScaler, + this.maxLines, + this.locale, + this.strutStyle, + required this.textWidthBasis, + this.textHeightBehavior, + required this.selectionColor, + }); + + final GlobalKey? textKey; + final InlineSpan text; + final TextAlign textAlign; + final TextDirection? textDirection; + final bool softWrap; + final TextOverflow overflow; + final TextScaler textScaler; + final int? maxLines; + final Locale? locale; + final StrutStyle? strutStyle; + final TextWidthBasis textWidthBasis; + final ui.TextHeightBehavior? textHeightBehavior; + final Color selectionColor; + + @override + Widget build(BuildContext context) { + final registrar = SelectionContainer.maybeOf(context); + return RichText( + key: textKey, + textAlign: textAlign, + textDirection: textDirection, + locale: locale, + softWrap: softWrap, + overflow: overflow, + textScaler: textScaler, + maxLines: maxLines, + strutStyle: strutStyle, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + selectionRegistrar: registrar, + selectionColor: selectionColor, + text: text, + ); + } +} + +// In practice some selectables like widgetspan shift several pixels. So when +// the vertical position diff is within the threshold, compare the horizontal +// position to make the compareScreenOrder function more robust. +const double _kSelectableVerticalComparingThreshold = 3.0; + +class _SelectableTextContainerDelegate + extends MultiSelectableSelectionContainerDelegate { + _SelectableTextContainerDelegate( + GlobalKey textKey, + ) : _textKey = textKey; + + final GlobalKey _textKey; + + TRenderParagraph get paragraph => + _textKey.currentContext!.findRenderObject()! as TRenderParagraph; + + @override + SelectionResult handleSelectParagraph(SelectParagraphSelectionEvent event) { + final result = _handleSelectParagraph(event); + if (currentSelectionStartIndex != -1) { + _hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]); + } + if (currentSelectionEndIndex != -1) { + _hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]); + } + _updateLastEdgeEventsFromGeometries(); + return result; + } + + SelectionResult _handleSelectParagraph(SelectParagraphSelectionEvent event) { + if (event.absorb) { + for (var index = 0; index < selectables.length; index += 1) { + dispatchSelectionEventToChild(selectables[index], event); + } + currentSelectionStartIndex = 0; + currentSelectionEndIndex = selectables.length - 1; + return SelectionResult.next; + } + + // First pass, if the position is on a placeholder then dispatch the selection + // event to the [Selectable] at the location and terminate. + for (var index = 0; index < selectables.length; index += 1) { + final selectableIsPlaceholder = + !paragraph.selectableBelongsToParagraph(selectables[index]); + if (selectableIsPlaceholder && + selectables[index].boundingBoxes.isNotEmpty) { + for (final rect in selectables[index].boundingBoxes) { + final globalRect = MatrixUtils.transformRect( + selectables[index].getTransformTo(null), rect); + if (globalRect.contains(event.globalPosition)) { + currentSelectionStartIndex = currentSelectionEndIndex = index; + return dispatchSelectionEventToChild(selectables[index], event); + } + } + } + } + + SelectionResult? lastSelectionResult; + var foundStart = false; + int? lastNextIndex; + for (var index = 0; index < selectables.length; index += 1) { + if (!paragraph.selectableBelongsToParagraph(selectables[index])) { + if (foundStart) { + final SelectionEvent synthesizedEvent = SelectParagraphSelectionEvent( + globalPosition: event.globalPosition, absorb: true); + final result = dispatchSelectionEventToChild( + selectables[index], synthesizedEvent); + if (selectables.length - 1 == index) { + currentSelectionEndIndex = index; + _flushInactiveSelections(); + return result; + } + } + continue; + } + final existingGeometry = selectables[index].value; + lastSelectionResult = + dispatchSelectionEventToChild(selectables[index], event); + if (index == selectables.length - 1 && + lastSelectionResult == SelectionResult.next) { + if (foundStart) { + currentSelectionEndIndex = index; + } else { + currentSelectionStartIndex = currentSelectionEndIndex = index; + } + return SelectionResult.next; + } + if (lastSelectionResult == SelectionResult.next) { + if (selectables[index].value == existingGeometry && !foundStart) { + lastNextIndex = index; + } + if (selectables[index].value != existingGeometry && !foundStart) { + assert(selectables[index].boundingBoxes.isNotEmpty); + assert(selectables[index].value.selectionRects.isNotEmpty); + final selectionAtStartOfSelectable = selectables[index] + .boundingBoxes[0] + .overlaps(selectables[index].value.selectionRects[0]); + var startIndex = 0; + if (lastNextIndex != null && selectionAtStartOfSelectable) { + startIndex = lastNextIndex + 1; + } else { + startIndex = lastNextIndex == null && selectionAtStartOfSelectable + ? 0 + : index; + } + for (var i = startIndex; i < index; i += 1) { + final SelectionEvent synthesizedEvent = + SelectParagraphSelectionEvent( + globalPosition: event.globalPosition, absorb: true); + dispatchSelectionEventToChild(selectables[i], synthesizedEvent); + } + currentSelectionStartIndex = startIndex; + foundStart = true; + } + continue; + } + if (index == 0 && lastSelectionResult == SelectionResult.previous) { + return SelectionResult.previous; + } + if (selectables[index].value != existingGeometry) { + if (!foundStart && lastNextIndex == null) { + currentSelectionStartIndex = 0; + for (var i = 0; i < index; i += 1) { + final SelectionEvent synthesizedEvent = + SelectParagraphSelectionEvent( + globalPosition: event.globalPosition, absorb: true); + dispatchSelectionEventToChild(selectables[i], synthesizedEvent); + } + } + currentSelectionEndIndex = index; + // Geometry has changed as a result of select paragraph, need to clear the + // selection of other selectables to keep selection in sync. + _flushInactiveSelections(); + } + return SelectionResult.end; + } + assert(lastSelectionResult == null); + return SelectionResult.end; + } + + /// Initializes the selection of the selectable children. + /// + /// The goal is to find the selectable child that contains the selection edge. + /// Returns [SelectionResult.end] if the selection edge ends on any of the + /// children. Otherwise, it returns [SelectionResult.previous] if the selection + /// does not reach any of its children. Returns [SelectionResult.next] + /// if the selection reaches the end of its children. + /// + /// Ideally, this method should only be called twice at the beginning of the + /// drag selection, once for start edge update event, once for end edge update + /// event. + SelectionResult _initSelection(SelectionEdgeUpdateEvent event, + {required bool isEnd}) { + assert((isEnd && currentSelectionEndIndex == -1) || + (!isEnd && currentSelectionStartIndex == -1)); + SelectionResult? finalResult; + // Begin the search for the selection edge at the opposite edge if it exists. + final hasOppositeEdge = isEnd + ? currentSelectionStartIndex != -1 + : currentSelectionEndIndex != -1; + var newIndex = switch ((isEnd, hasOppositeEdge)) { + (true, true) => currentSelectionStartIndex, + (true, false) => 0, + (false, true) => currentSelectionEndIndex, + (false, false) => 0, + }; + bool? forward; + late SelectionResult currentSelectableResult; + // This loop sends the selection event to one of the following to determine + // the direction of the search. + // - The opposite edge index if it exists. + // - Index 0 if the opposite edge index does not exist. + // + // If the result is `SelectionResult.next`, this loop look backward. + // Otherwise, it looks forward. + // + // The terminate condition are: + // 1. the selectable returns end, pending, none. + // 2. the selectable returns previous when looking forward. + // 2. the selectable returns next when looking backward. + while ( + newIndex < selectables.length && newIndex >= 0 && finalResult == null) { + currentSelectableResult = + dispatchSelectionEventToChild(selectables[newIndex], event); + switch (currentSelectableResult) { + case SelectionResult.end: + case SelectionResult.pending: + case SelectionResult.none: + finalResult = currentSelectableResult; + case SelectionResult.next: + if (forward == false) { + newIndex += 1; + finalResult = SelectionResult.end; + } else if (newIndex == selectables.length - 1) { + finalResult = currentSelectableResult; + } else { + forward = true; + newIndex += 1; + } + case SelectionResult.previous: + if (forward ?? false) { + newIndex -= 1; + finalResult = SelectionResult.end; + } else if (newIndex == 0) { + finalResult = currentSelectableResult; + } else { + forward = false; + newIndex -= 1; + } + } + } + if (isEnd) { + currentSelectionEndIndex = newIndex; + } else { + currentSelectionStartIndex = newIndex; + } + _flushInactiveSelections(); + return finalResult!; + } + + SelectionResult _adjustSelection(SelectionEdgeUpdateEvent event, + {required bool isEnd}) { + assert(() { + if (isEnd) { + assert(currentSelectionEndIndex < selectables.length && + currentSelectionEndIndex >= 0); + return true; + } + assert(currentSelectionStartIndex < selectables.length && + currentSelectionStartIndex >= 0); + return true; + }()); + SelectionResult? finalResult; + // Determines if the edge being adjusted is within the current viewport. + // - If so, we begin the search for the new selection edge position at the + // currentSelectionEndIndex/currentSelectionStartIndex. + // - If not, we attempt to locate the new selection edge starting from + // the opposite end. + // - If neither edge is in the current viewport, the search for the new + // selection edge position begins at 0. + // + // This can happen when there is a scrollable child and the edge being adjusted + // has been scrolled out of view. + final isCurrentEdgeWithinViewport = isEnd + ? value.endSelectionPoint != null + : value.startSelectionPoint != null; + final isOppositeEdgeWithinViewport = isEnd + ? value.startSelectionPoint != null + : value.endSelectionPoint != null; + var newIndex = switch (( + isEnd, + isCurrentEdgeWithinViewport, + isOppositeEdgeWithinViewport + )) { + (true, true, true) => currentSelectionEndIndex, + (true, true, false) => currentSelectionEndIndex, + (true, false, true) => currentSelectionStartIndex, + (true, false, false) => 0, + (false, true, true) => currentSelectionStartIndex, + (false, true, false) => currentSelectionStartIndex, + (false, false, true) => currentSelectionEndIndex, + (false, false, false) => 0, + }; + bool? forward; + late SelectionResult currentSelectableResult; + // This loop sends the selection event to one of the following to determine + // the direction of the search. + // - currentSelectionEndIndex/currentSelectionStartIndex if the current edge + // is in the current viewport. + // - The opposite edge index if the current edge is not in the current viewport. + // - Index 0 if neither edge is in the current viewport. + // + // If the result is `SelectionResult.next`, this loop look backward. + // Otherwise, it looks forward. + // + // The terminate condition are: + // 1. the selectable returns end, pending, none. + // 2. the selectable returns previous when looking forward. + // 2. the selectable returns next when looking backward. + while ( + newIndex < selectables.length && newIndex >= 0 && finalResult == null) { + currentSelectableResult = + dispatchSelectionEventToChild(selectables[newIndex], event); + switch (currentSelectableResult) { + case SelectionResult.end: + case SelectionResult.pending: + case SelectionResult.none: + finalResult = currentSelectableResult; + case SelectionResult.next: + if (forward == false) { + newIndex += 1; + finalResult = SelectionResult.end; + } else if (newIndex == selectables.length - 1) { + finalResult = currentSelectableResult; + } else { + forward = true; + newIndex += 1; + } + case SelectionResult.previous: + if (forward ?? false) { + newIndex -= 1; + finalResult = SelectionResult.end; + } else if (newIndex == 0) { + finalResult = currentSelectableResult; + } else { + forward = false; + newIndex -= 1; + } + } + } + if (isEnd) { + final forwardSelection = + currentSelectionEndIndex >= currentSelectionStartIndex; + if (forward != null && + ((!forwardSelection && + forward && + newIndex >= currentSelectionStartIndex) || + (forwardSelection && + !forward && + newIndex <= currentSelectionStartIndex))) { + currentSelectionStartIndex = currentSelectionEndIndex; + } + currentSelectionEndIndex = newIndex; + } else { + final forwardSelection = + currentSelectionEndIndex >= currentSelectionStartIndex; + if (forward != null && + ((!forwardSelection && + !forward && + newIndex <= currentSelectionEndIndex) || + (forwardSelection && + forward && + newIndex >= currentSelectionEndIndex))) { + currentSelectionEndIndex = currentSelectionStartIndex; + } + currentSelectionStartIndex = newIndex; + } + _flushInactiveSelections(); + return finalResult!; + } + + /// The compare function this delegate used for determining the selection + /// order of the [Selectable]s. + /// + /// Sorts the [Selectable]s by their top left [Rect]. + @override + Comparator get compareOrder => _compareScreenOrder; + + static int _compareScreenOrder(Selectable a, Selectable b) { + // Attempt to sort the selectables under a [_SelectableTextContainerDelegate] + // by the top left rect. + final rectA = MatrixUtils.transformRect( + a.getTransformTo(null), + a.boundingBoxes.first, + ); + final rectB = MatrixUtils.transformRect( + b.getTransformTo(null), + b.boundingBoxes.first, + ); + final result = _compareVertically(rectA, rectB); + if (result != 0) { + return result; + } + return _compareHorizontally(rectA, rectB); + } + + /// Compares two rectangles in the screen order solely by their vertical + /// positions. + /// + /// Returns positive if a is lower, negative if a is higher, 0 if their + /// order can't be determine solely by their vertical position. + static int _compareVertically(Rect a, Rect b) { + // The rectangles overlap so defer to horizontal comparison. + if ((a.top - b.top < _kSelectableVerticalComparingThreshold && + a.bottom - b.bottom > -_kSelectableVerticalComparingThreshold) || + (b.top - a.top < _kSelectableVerticalComparingThreshold && + b.bottom - a.bottom > -_kSelectableVerticalComparingThreshold)) { + return 0; + } + if ((a.top - b.top).abs() > _kSelectableVerticalComparingThreshold) { + return a.top > b.top ? 1 : -1; + } + return a.bottom > b.bottom ? 1 : -1; + } + + /// Compares two rectangles in the screen order by their horizontal positions + /// assuming one of the rectangles enclose the other rect vertically. + /// + /// Returns positive if a is lower, negative if a is higher. + static int _compareHorizontally(Rect a, Rect b) { + // a encloses b. + if (a.left - b.left < precisionErrorTolerance && + a.right - b.right > -precisionErrorTolerance) { + return -1; + } + // b encloses a. + if (b.left - a.left < precisionErrorTolerance && + b.right - a.right > -precisionErrorTolerance) { + return 1; + } + if ((a.left - b.left).abs() > precisionErrorTolerance) { + return a.left > b.left ? 1 : -1; + } + return a.right > b.right ? 1 : -1; + } + + // From [SelectableRegion]. + + // Clears the selection on all selectables not in the range of + // currentSelectionStartIndex..currentSelectionEndIndex. + // + // If one of the edges does not exist, then this method will clear the selection + // in all selectables except the existing edge. + // + // If neither of the edges exist this method immediately returns. + void _flushInactiveSelections() { + if (currentSelectionStartIndex == -1 && currentSelectionEndIndex == -1) { + return; + } + if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) { + final skipIndex = currentSelectionStartIndex == -1 + ? currentSelectionEndIndex + : currentSelectionStartIndex; + selectables + .where((Selectable target) => target != selectables[skipIndex]) + .forEach((Selectable target) => dispatchSelectionEventToChild( + target, const ClearSelectionEvent())); + return; + } + final int skipStart = + min(currentSelectionStartIndex, currentSelectionEndIndex); + final int skipEnd = + max(currentSelectionStartIndex, currentSelectionEndIndex); + for (var index = 0; index < selectables.length; index += 1) { + if (index >= skipStart && index <= skipEnd) { + continue; + } + dispatchSelectionEventToChild( + selectables[index], const ClearSelectionEvent()); + } + } + + final Set _hasReceivedStartEvent = {}; + final Set _hasReceivedEndEvent = {}; + + Offset? _lastStartEdgeUpdateGlobalPosition; + Offset? _lastEndEdgeUpdateGlobalPosition; + + @override + void remove(Selectable selectable) { + _hasReceivedStartEvent.remove(selectable); + _hasReceivedEndEvent.remove(selectable); + super.remove(selectable); + } + + void _updateLastEdgeEventsFromGeometries() { + if (currentSelectionStartIndex != -1 && + selectables[currentSelectionStartIndex].value.hasSelection) { + final start = selectables[currentSelectionStartIndex]; + final localStartEdge = start.value.startSelectionPoint!.localPosition + + Offset(0, -start.value.startSelectionPoint!.lineHeight / 2); + _lastStartEdgeUpdateGlobalPosition = MatrixUtils.transformPoint( + start.getTransformTo(null), localStartEdge); + } + if (currentSelectionEndIndex != -1 && + selectables[currentSelectionEndIndex].value.hasSelection) { + final end = selectables[currentSelectionEndIndex]; + final localEndEdge = end.value.endSelectionPoint!.localPosition + + Offset(0, -end.value.endSelectionPoint!.lineHeight / 2); + _lastEndEdgeUpdateGlobalPosition = + MatrixUtils.transformPoint(end.getTransformTo(null), localEndEdge); + } + } + + @override + SelectionResult handleSelectAll(SelectAllSelectionEvent event) { + final result = super.handleSelectAll(event); + for (final selectable in selectables) { + _hasReceivedStartEvent.add(selectable); + _hasReceivedEndEvent.add(selectable); + } + // Synthesize last update event so the edge updates continue to work. + _updateLastEdgeEventsFromGeometries(); + return result; + } + + /// Selects a word in a selectable at the location + /// [SelectWordSelectionEvent.globalPosition]. + @override + SelectionResult handleSelectWord(SelectWordSelectionEvent event) { + final result = super.handleSelectWord(event); + if (currentSelectionStartIndex != -1) { + _hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]); + } + if (currentSelectionEndIndex != -1) { + _hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]); + } + _updateLastEdgeEventsFromGeometries(); + return result; + } + + @override + SelectionResult handleClearSelection(ClearSelectionEvent event) { + final result = super.handleClearSelection(event); + _hasReceivedStartEvent.clear(); + _hasReceivedEndEvent.clear(); + _lastStartEdgeUpdateGlobalPosition = null; + _lastEndEdgeUpdateGlobalPosition = null; + return result; + } + + @override + SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) { + if (event.type == SelectionEventType.endEdgeUpdate) { + _lastEndEdgeUpdateGlobalPosition = event.globalPosition; + } else { + _lastStartEdgeUpdateGlobalPosition = event.globalPosition; + } + + if (event.granularity == TextGranularity.paragraph) { + if (event.type == SelectionEventType.endEdgeUpdate) { + return currentSelectionEndIndex == -1 + ? _initSelection(event, isEnd: true) + : _adjustSelection(event, isEnd: true); + } + return currentSelectionStartIndex == -1 + ? _initSelection(event, isEnd: false) + : _adjustSelection(event, isEnd: false); + } + + return super.handleSelectionEdgeUpdate(event); + } + + @override + void dispose() { + _hasReceivedStartEvent.clear(); + _hasReceivedEndEvent.clear(); + super.dispose(); + } + + @override + SelectionResult dispatchSelectionEventToChild( + Selectable selectable, SelectionEvent event) { + switch (event.type) { + case SelectionEventType.startEdgeUpdate: + _hasReceivedStartEvent.add(selectable); + ensureChildUpdated(selectable); + case SelectionEventType.endEdgeUpdate: + _hasReceivedEndEvent.add(selectable); + ensureChildUpdated(selectable); + case SelectionEventType.clear: + _hasReceivedStartEvent.remove(selectable); + _hasReceivedEndEvent.remove(selectable); + case SelectionEventType.selectAll: + case SelectionEventType.selectWord: + case SelectionEventType.selectParagraph: + break; + case SelectionEventType.granularlyExtendSelection: + case SelectionEventType.directionallyExtendSelection: + _hasReceivedStartEvent.add(selectable); + _hasReceivedEndEvent.add(selectable); + ensureChildUpdated(selectable); + } + return super.dispatchSelectionEventToChild(selectable, event); + } + + @override + void ensureChildUpdated(Selectable selectable) { + if (_lastEndEdgeUpdateGlobalPosition != null && + _hasReceivedEndEvent.add(selectable)) { + final synthesizedEvent = SelectionEdgeUpdateEvent.forEnd( + globalPosition: _lastEndEdgeUpdateGlobalPosition!, + ); + if (currentSelectionEndIndex == -1) { + handleSelectionEdgeUpdate(synthesizedEvent); + } + selectable.dispatchSelectionEvent(synthesizedEvent); + } + if (_lastStartEdgeUpdateGlobalPosition != null && + _hasReceivedStartEvent.add(selectable)) { + final synthesizedEvent = SelectionEdgeUpdateEvent.forStart( + globalPosition: _lastStartEdgeUpdateGlobalPosition!, + ); + if (currentSelectionStartIndex == -1) { + handleSelectionEdgeUpdate(synthesizedEvent); + } + selectable.dispatchSelectionEvent(synthesizedEvent); + } + } + + @override + void didChangeSelectables() { + if (_lastEndEdgeUpdateGlobalPosition != null) { + handleSelectionEdgeUpdate( + SelectionEdgeUpdateEvent.forEnd( + globalPosition: _lastEndEdgeUpdateGlobalPosition!, + ), + ); + } + if (_lastStartEdgeUpdateGlobalPosition != null) { + handleSelectionEdgeUpdate( + SelectionEdgeUpdateEvent.forStart( + globalPosition: _lastStartEdgeUpdateGlobalPosition!, + ), + ); + } + final selectableSet = selectables.toSet(); + _hasReceivedEndEvent.removeWhere( + (Selectable selectable) => !selectableSet.contains(selectable)); + _hasReceivedStartEvent.removeWhere( + (Selectable selectable) => !selectableSet.contains(selectable)); + super.didChangeSelectables(); + } +} + +/// A paragraph of rich text. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=rykDVh-QFfw} +/// +/// The [RichText] widget displays text that uses multiple different styles. The +/// text to display is described using a tree of [TextSpan] objects, each of +/// which has an associated style that is used for that subtree. The text might +/// break across multiple lines or might all be displayed on the same line +/// depending on the layout constraints. +/// +/// Text displayed in a [RichText] widget must be explicitly styled. When +/// picking which style to use, consider using [DefaultTextStyle.of] the current +/// [BuildContext] to provide defaults. For more details on how to style text in +/// a [RichText] widget, see the documentation for [TextStyle]. +/// +/// Consider using the [TText] widget to integrate with the [DefaultTextStyle] +/// automatically. When all the text uses the same style, the default constructor +/// is less verbose. The [Text.rich] constructor allows you to style multiple +/// spans with the default text style while still allowing specified styles per +/// span. +/// +/// {@tool snippet} +/// +/// This sample demonstrates how to mix and match text with different text +/// styles using the [RichText] Widget. It displays the text "Hello bold world," +/// emphasizing the word "bold" using a bold font weight. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/widgets/rich_text.png) +/// +/// ```dart +/// RichText( +/// text: TextSpan( +/// text: 'Hello ', +/// style: DefaultTextStyle.of(context).style, +/// children: const [ +/// TextSpan(text: 'bold', style: TextStyle(fontWeight: FontWeight.bold)), +/// TextSpan(text: ' world!'), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Selections +/// +/// To make this [RichText] Selectable, the [RichText] needs to be in the +/// subtree of a [SelectionArea] or [SelectableRegion] and a +/// [SelectionRegistrar] needs to be assigned to the +/// [RichText.selectionRegistrar]. One can use +/// [SelectionContainer.maybeOf] to get the [SelectionRegistrar] from a +/// context. This enables users to select the text in [RichText]s with mice or +/// touch events. +/// +/// The [selectionColor] also needs to be set if the selection is enabled to +/// draw the selection highlights. +/// +/// {@tool snippet} +/// +/// This sample demonstrates how to assign a [SelectionRegistrar] for RichTexts +/// in the SelectionArea subtree. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/widgets/rich_text.png) +/// +/// ```dart +/// RichText( +/// text: const TextSpan(text: 'Hello'), +/// selectionRegistrar: SelectionContainer.maybeOf(context), +/// selectionColor: const Color(0xAF6694e8), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [TextStyle], which discusses how to style text. +/// * [TextSpan], which is used to describe the text in a paragraph. +/// * [TText], which automatically applies the ambient styles described by a +/// [DefaultTextStyle] to a single string. +/// * [Text.rich], a const text widget that provides similar functionality +/// as [RichText]. [Text.rich] will inherit [TextStyle] from [DefaultTextStyle]. +/// * [SelectableRegion], which provides an overview of the selection system. +class RichText extends MultiChildRenderObjectWidget { + /// Creates a paragraph of rich text. + /// + /// The [maxLines] property may be null (and indeed defaults to null), but if + /// it is not null, it must be greater than zero. + /// + /// The [textDirection], if null, defaults to the ambient [Directionality], + /// which in that case must not be null. + RichText({ + super.key, + required this.text, + this.textAlign = TextAlign.start, + this.textDirection, + this.softWrap = true, + this.overflow = TextOverflow.clip, + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + double textScaleFactor = 1.0, + TextScaler textScaler = TextScaler.noScaling, + this.maxLines, + this.locale, + this.strutStyle, + this.textWidthBasis = TextWidthBasis.parent, + this.textHeightBehavior, + this.selectionRegistrar, + this.selectionColor, + }) : assert(maxLines == null || maxLines > 0), + assert(selectionRegistrar == null || selectionColor != null), + assert( + textScaleFactor == 1.0 || + identical(textScaler, TextScaler.noScaling), + 'Use textScaler instead.'), + textScaler = _effectiveTextScalerFrom(textScaler, textScaleFactor), + super( + children: WidgetSpan.extractFromInlineSpan( + text, _effectiveTextScalerFrom(textScaler, textScaleFactor))); + + static TextScaler _effectiveTextScalerFrom( + TextScaler textScaler, double textScaleFactor) => + switch ((textScaler, textScaleFactor)) { + (final TextScaler scaler, 1.0) => scaler, + (TextScaler.noScaling, final double textScaleFactor) => + TextScaler.linear(textScaleFactor), + (final TextScaler scaler, _) => scaler, + }; + + /// The text to display in this widget. + final InlineSpan text; + + /// How the text should be aligned horizontally. + final TextAlign textAlign; + + /// The directionality of the text. + /// + /// This decides how [textAlign] values like [TextAlign.start] and + /// [TextAlign.end] are interpreted. + /// + /// This is also used to disambiguate how to render bidirectional text. For + /// example, if the [text] is an English phrase followed by a Hebrew phrase, + /// in a [TextDirection.ltr] context the English phrase will be on the left + /// and the Hebrew phrase to its right, while in a [TextDirection.rtl] + /// context, the English phrase will be on the right and the Hebrew phrase on + /// its left. + /// + /// Defaults to the ambient [Directionality], if any. If there is no ambient + /// [Directionality], then this must not be null. + final TextDirection? textDirection; + + /// Whether the text should break at soft line breaks. + /// + /// If false, the glyphs in the text will be positioned as if there was unlimited horizontal space. + final bool softWrap; + + /// How visual overflow should be handled. + final TextOverflow overflow; + + /// Deprecated. Will be removed in a future version of Flutter. Use + /// [textScaler] instead. + /// + /// The number of font pixels for each logical pixel. + /// + /// For example, if the text scale factor is 1.5, text will be 50% larger than + /// the specified font size. + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + double get textScaleFactor => textScaler.textScaleFactor; + + /// {@macro flutter.painting.textPainter.textScaler} + final TextScaler textScaler; + + /// An optional maximum number of lines for the text to span, wrapping if necessary. + /// If the text exceeds the given number of lines, it will be truncated according + /// to [overflow]. + /// + /// If this is 1, text will not wrap. Otherwise, text will be wrapped at the + /// edge of the box. + final int? maxLines; + + /// Used to select a font when the same Unicode character can + /// be rendered differently, depending on the locale. + /// + /// It's rarely necessary to set this property. By default its value + /// is inherited from the enclosing app with `Localizations.localeOf(context)`. + /// + /// See [RenderParagraph.locale] for more information. + final Locale? locale; + + /// {@macro flutter.painting.textPainter.strutStyle} + final StrutStyle? strutStyle; + + /// {@macro flutter.painting.textPainter.textWidthBasis} + final TextWidthBasis textWidthBasis; + + /// {@macro dart.ui.textHeightBehavior} + final ui.TextHeightBehavior? textHeightBehavior; + + /// The [SelectionRegistrar] this rich text is subscribed to. + /// + /// If this is set, [selectionColor] must be non-null. + final SelectionRegistrar? selectionRegistrar; + + /// The color to use when painting the selection. + /// + /// This is ignored if [selectionRegistrar] is null. + /// + /// See the section on selections in the [RichText] top-level API + /// documentation for more details on enabling selection in [RichText] + /// widgets. + final Color? selectionColor; + + @override + TRenderParagraph createRenderObject(BuildContext context) { + assert(textDirection != null || debugCheckHasDirectionality(context)); + return TRenderParagraph( + text, + textAlign: textAlign, + textDirection: textDirection ?? Directionality.of(context), + softWrap: softWrap, + overflow: overflow, + textScaler: textScaler, + maxLines: maxLines, + strutStyle: strutStyle, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + locale: locale ?? Localizations.maybeLocaleOf(context), + registrar: selectionRegistrar, + selectionColor: selectionColor, + ); + } + + @override + void updateRenderObject(BuildContext context, TRenderParagraph renderObject) { + assert(textDirection != null || debugCheckHasDirectionality(context)); + renderObject + ..text = text + ..textAlign = textAlign + ..textDirection = textDirection ?? Directionality.of(context) + ..softWrap = softWrap + ..overflow = overflow + ..textScaler = textScaler + ..maxLines = maxLines + ..strutStyle = strutStyle + ..textWidthBasis = textWidthBasis + ..textHeightBehavior = textHeightBehavior + ..locale = locale ?? Localizations.maybeLocaleOf(context) + ..registrar = selectionRegistrar + ..selectionColor = selectionColor; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(EnumProperty('textAlign', textAlign, + defaultValue: TextAlign.start)); + properties.add(EnumProperty('textDirection', textDirection, + defaultValue: null)); + properties.add(FlagProperty('softWrap', + value: softWrap, + ifTrue: 'wrapping at box width', + ifFalse: 'no wrapping except at line break characters', + showName: true)); + properties.add(EnumProperty('overflow', overflow, + defaultValue: TextOverflow.clip)); + properties.add(DiagnosticsProperty('textScaler', textScaler, + defaultValue: TextScaler.noScaling)); + properties.add(IntProperty('maxLines', maxLines, ifNull: 'unlimited')); + properties.add(EnumProperty( + 'textWidthBasis', textWidthBasis, + defaultValue: TextWidthBasis.parent)); + properties.add(StringProperty('text', text.toPlainText())); + properties + .add(DiagnosticsProperty('locale', locale, defaultValue: null)); + properties.add(DiagnosticsProperty('strutStyle', strutStyle, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'textHeightBehavior', textHeightBehavior, + defaultValue: null)); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_text_field/t_text_field.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_text_field/t_text_field.dart new file mode 100644 index 0000000000..7aba594176 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_text_field/t_text_field.dart @@ -0,0 +1,293 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../infra/task/debouncer.dart'; +import '../../../l10n/view_models/app_localizations_view_model.dart'; +import '../index.dart'; +import '../t_menu/t_context_menu.dart'; + +class TTextField extends ConsumerStatefulWidget { + const TTextField( + {super.key, + this.textEditingController, + this.autofocus = false, + this.keepFocusOnSubmit = false, + this.focusNode, + this.mouseCursor, + this.hintText, + this.prefixIcon, + this.prefixIconConstraints, + this.suffixIcon, + this.showDeleteButtonIfHasText = false, + this.showCursor = true, + this.readOnly = false, + this.enableInteractiveSelection, + this.maxLength, + this.expands = false, + this.style, + this.textAlign = TextAlign.start, + TextAlignVertical? textAlignVertical, + this.debounceTimeout, + this.transformValue, + this.onChanged, + this.onSubmitted, + this.onCaretMoved, + this.onTapOutside}) + : assert(!showDeleteButtonIfHasText || suffixIcon == null), + textAlignVertical = textAlignVertical ?? + (expands ? TextAlignVertical.top : TextAlignVertical.center); + + final TextEditingController? textEditingController; + final bool autofocus; + final bool keepFocusOnSubmit; + final FocusNode? focusNode; + final SystemMouseCursor? mouseCursor; + final String? hintText; + final Widget? prefixIcon; + final BoxConstraints? prefixIconConstraints; + final Widget? suffixIcon; + final bool showDeleteButtonIfHasText; + final bool showCursor; + final bool readOnly; + final bool? enableInteractiveSelection; + final int? maxLength; + final bool expands; + final TextAlign textAlign; + final TextAlignVertical textAlignVertical; + final TextStyle? style; + final Duration? debounceTimeout; + final String Function(String value)? transformValue; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final ValueChanged? onCaretMoved; + final ValueChanged? onTapOutside; + + @override + ConsumerState createState() => _TTextFieldState(); +} + +class _TTextFieldState extends ConsumerState { + GlobalKey? _textFieldKey; + TextEditingController? _textEditingController; + Debouncer? _onChangedDebouncer; + FocusNode? _focusNode; + + @override + void initState() { + super.initState(); + if (widget.textEditingController == null) { + _textEditingController = TextEditingController(); + } + final debounceTimeout = widget.debounceTimeout; + if (debounceTimeout != null) { + _onChangedDebouncer = Debouncer(timeout: debounceTimeout); + } + if (widget.onCaretMoved != null) { + _textFieldKey = GlobalKey(); + } + _focusNode = widget.focusNode == null && widget.keepFocusOnSubmit + ? FocusNode() + : null; + } + + @override + void dispose() { + _textEditingController?.dispose(); + _onChangedDebouncer?.cancel(); + _focusNode?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final prefixIcon = widget.prefixIcon; + final controller = + (widget.textEditingController ?? _textEditingController)!; + final showSuffixIcon = (widget.suffixIcon != null) || + (widget.showDeleteButtonIfHasText && controller.text.isNotEmpty); + final suffixIcon = widget.suffixIcon; + return TextField( + key: _textFieldKey, + controller: controller, + contextMenuBuilder: buildTextFieldContextMenu, + autofocus: widget.autofocus, + focusNode: widget.focusNode ?? _focusNode, + mouseCursor: widget.mouseCursor, + showCursor: widget.showCursor, + readOnly: widget.readOnly, + maxLines: widget.expands ? null : 1, + maxLength: widget.maxLength, + textAlign: widget.textAlign, + textAlignVertical: widget.textAlignVertical, + enableInteractiveSelection: widget.enableInteractiveSelection, + expands: widget.expands, + onChanged: (value) { + // To get an accurate "controller.value.composing", + // we have to use "addPostFrameCallback". + // FIXME: https://github.com/flutter/flutter/issues/128565 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (controller.value.composing != TextRange.empty) { + return; + } + final transformValue = widget.transformValue; + if (transformValue != null) { + final result = transformValue(value); + if (result != value) { + controller.value = TextEditingValue( + text: result, + selection: TextSelection.collapsed(offset: result.length), + ); + value = result; + } + } + final debouncer = _onChangedDebouncer; + if (debouncer != null) { + debouncer.run(() { + widget.onChanged?.call(value); + _tryNotifyCaretMoved(); + }); + } else { + widget.onChanged?.call(value); + _tryNotifyCaretMoved(); + } + setState(() {}); + }); + }, + onSubmitted: _onChangedDebouncer == null + ? widget.keepFocusOnSubmit + ? (value) { + widget.onSubmitted?.call(value); + if (widget.keepFocusOnSubmit) { + _focusNode?.requestFocus(); + } + } + : widget.onSubmitted + : (value) { + _onChangedDebouncer?.cancel(); + widget.onSubmitted?.call(value); + if (widget.keepFocusOnSubmit) { + _focusNode?.requestFocus(); + } + }, + onTapOutside: widget.onTapOutside, + style: widget.style ?? + const TextStyle( + fontSize: 14, + // cursor height + height: 1.2), + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: TextStyle(color: Colors.grey.shade600), + filled: true, + fillColor: const Color.fromARGB(255, 226, 226, 226), + contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), + prefixIcon: prefixIcon, + prefixIconConstraints: prefixIcon == null + ? null + : const BoxConstraints.tightFor(width: 24), + suffixIcon: suffixIcon ?? + (showSuffixIcon + ? TIconButton( + addContainer: false, + iconData: Symbols.close_rounded, + iconSize: 20, + tooltip: ref.watch(appLocalizationsViewModel).close, + onTap: () { + controller.clear(); + widget.onChanged?.call(''); + setState(() {}); + }, + ) + : null), + suffixIconConstraints: + showSuffixIcon ? const BoxConstraints.tightFor(width: 30) : null, + isCollapsed: true, + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.blue), + ), + border: const OutlineInputBorder( + borderSide: BorderSide.none, + ), + ), + ); + } + + void _tryNotifyCaretMoved() { + final onCaretMoved = widget.onCaretMoved; + if (onCaretMoved != null) { + final rect = getCaretRect(_textFieldKey!); + if (rect != null) { + onCaretMoved(rect); + } + } + } +} + +Rect? getCaretRect(GlobalKey textFieldKey) { + final currentContext = textFieldKey.currentContext; + if (currentContext == null) { + return null; + } + final fieldBox = currentContext.findRenderObject(); + final caretRect = fieldBox is RenderBox ? _getCaretRect(fieldBox) : null; + if (caretRect == null) { + return null; + } + return caretRect; +} + +RenderEditable? _findRenderEditable(RenderObject root) { + RenderEditable? renderEditable; + void recursiveFinder(RenderObject child) { + if (child is RenderEditable) { + renderEditable = child; + return; + } + child.visitChildren(recursiveFinder); + } + + root.visitChildren(recursiveFinder); + return renderEditable; +} + +Rect? _getCaretRect(RenderBox box) { + final renderEditable = _findRenderEditable(box); + if (renderEditable == null || !renderEditable.hasFocus) { + return null; + } + final selection = renderEditable.selection; + if (selection == null) { + return null; + } + final firstEndpoint = + renderEditable.getEndpointsForSelection(selection).firstOrNull; + if (firstEndpoint == null) { + return null; + } + + final point = TextSelectionPoint( + box.localToGlobal(firstEndpoint.point), + firstEndpoint.direction, + ); + + final p = point.point; + final cursorHeight = renderEditable.cursorHeight; + return Rect.fromLTWH( + p.dx, + p.dy - cursorHeight, + renderEditable.cursorWidth, + cursorHeight, + ); +} + +Widget buildTextFieldContextMenu( + BuildContext context, + EditableTextState editableTextState, +) => + buildContextMenu( + context: context, + items: editableTextState.contextMenuButtonItems, + anchors: editableTextState.contextMenuAnchors); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_title_bar/t_title_bar.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_title_bar/t_title_bar.dart new file mode 100644 index 0000000000..79424c18c7 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_title_bar/t_title_bar.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../domain/user/models/setting_action_on_close.dart'; +import '../../../../domain/user/view_models/user_settings_view_model.dart'; +import '../../../../infra/app/app_utils.dart'; +import '../../../../infra/ui/color_extensions.dart'; +import '../../../../infra/window/window_utils.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../themes/index.dart'; + +import '../t_button/t_icon_button.dart'; + +// TODO: Support MacOS design +class TTitleBar extends ConsumerStatefulWidget { + const TTitleBar( + {super.key, + this.displayCloseOnly = false, + this.popOnCloseTapped = false, + this.usePositioned = true, + this.backgroundColor = AppColors.transparentWhite}); + + final bool displayCloseOnly; + final bool popOnCloseTapped; + final bool usePositioned; + final Color backgroundColor; + + @override + ConsumerState createState() => _TTitleBarState(); +} + +class _TTitleBarState extends ConsumerState { + bool _isAlwaysOnTop = false; + + @override + Widget build(BuildContext context) { + final localizations = ref.watch(appLocalizationsViewModel); + final child = widget.displayCloseOnly + ? _buildCloseButton(context, localizations) + : Row(children: [ + _buildSetAlwaysOnTopButton(context.theme, localizations), + _buildMinimizeButton(localizations), + _buildMaximizeButton(localizations), + _buildCloseButton(context, localizations), + ]); + return widget.usePositioned + ? Positioned(top: 0, right: 0, child: child) + : child; + } + + TIconButton _buildSetAlwaysOnTopButton( + ThemeData theme, AppLocalizations localizations) => + TIconButton( + containerSize: Sizes.titleBarSize, + containerColor: _isAlwaysOnTop + ? widget.backgroundColor.darken() + : widget.backgroundColor, + containerColorHovered: const Color.fromARGB(255, 226, 226, 226), + containerBorderRadius: BorderRadius.zero, + iconData: Symbols.push_pin_rounded, + iconSize: 16, + iconColor: _isAlwaysOnTop + ? theme.primaryColor + : const Color.fromARGB(255, 67, 67, 67), + onTap: () async { + setState(() => _isAlwaysOnTop = !_isAlwaysOnTop); + await WindowUtils.setAlwaysOnTop(_isAlwaysOnTop); + }, + tooltip: _isAlwaysOnTop + ? localizations.alwaysOnTopDisable + : localizations.alwaysOnTopEnable); + + TIconButton _buildMinimizeButton(AppLocalizations localizations) => + TIconButton( + containerSize: Sizes.titleBarSize, + containerColor: widget.backgroundColor, + containerColorHovered: const Color.fromARGB(255, 226, 226, 226), + containerBorderRadius: BorderRadius.zero, + iconData: Symbols.horizontal_rule_rounded, + iconSize: 16, + iconColor: const Color.fromARGB(255, 67, 67, 67), + onTap: WindowUtils.minimize, + tooltip: localizations.minimize); + + TIconButton _buildMaximizeButton(AppLocalizations localizations) { + final isWindowMaximized = ref.watch(isWindowMaximizedViewModel); + return TIconButton( + containerSize: Sizes.titleBarSize, + containerColor: widget.backgroundColor, + containerColorHovered: const Color.fromARGB(255, 226, 226, 226), + containerBorderRadius: BorderRadius.zero, + iconData: isWindowMaximized + ? Symbols.stack_rounded + : Symbols.crop_square_rounded, + iconSize: 16, + iconColor: const Color.fromARGB(255, 67, 67, 67), + iconFlipX: isWindowMaximized, + onTap: () async { + if (isWindowMaximized) { + await WindowUtils.unmaximize(); + } else { + await WindowUtils.maximize(); + } + }, + tooltip: + isWindowMaximized ? localizations.restore : localizations.maximize, + ); + } + + TIconButton _buildCloseButton( + BuildContext context, AppLocalizations localizations) => + TIconButton( + containerSize: Sizes.titleBarSize, + containerColor: widget.backgroundColor, + containerColorHovered: Colors.red, + containerBorderRadius: BorderRadius.zero, + iconData: Symbols.close_rounded, + iconSize: 16, + iconColor: const Color.fromARGB(255, 67, 67, 67), + iconColorHovered: Colors.white, + tooltip: localizations.close, + onTap: widget.popOnCloseTapped + ? () => Navigator.of(context).pop() + : () => switch (ref.read(userSettingsViewModel)?.actionOnClose ?? + SettingActionOnClose.exit) { + SettingActionOnClose.minimizeToTray => WindowUtils.hide(), + SettingActionOnClose.exit => AppUtils.close(), + }, + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_toast/t_toast.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_toast/t_toast.dart new file mode 100644 index 0000000000..b0277eea06 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_toast/t_toast.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../themes/index.dart'; +import '../t_circle/t_circle.dart'; +import 't_toast_type.dart'; +import 't_toast_view.dart'; + +class TToast { + TToast._(); + + static OverlayState? _overlayState; + static OverlayEntry? _overlayEntry; + static bool _isVisible = false; + + static Future showToast( + BuildContext context, + String text, { + Duration toastDuration = const Duration(seconds: 3), + TToastType type = TToastType.info, + }) async { + // TODO: support displaying multiple toasts at the same time. + dismiss(); + _overlayState = Overlay.of(context); + final theme = context.theme; + final appThemeExtension = theme.appThemeExtension; + + final Widget toastChild = TToastView( + child: DecoratedBox( + decoration: appThemeExtension.toastDecoration, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + switch (type) { + TToastType.info => TCircle( + backgroundColor: appThemeExtension.infoColor, + child: const Icon( + Symbols.info_i_rounded, + color: Colors.white, + size: 14, + )), + TToastType.success => TCircle( + backgroundColor: appThemeExtension.successColor, + child: const Icon( + Symbols.done_rounded, + color: Colors.white, + size: 14, + )), + TToastType.error => TCircle( + backgroundColor: theme.colorScheme.error, + child: const Icon( + Symbols.close_rounded, + color: Colors.white, + size: 14, + )), + TToastType.warning => TCircle( + backgroundColor: appThemeExtension.warningColor, + child: const Icon( + Symbols.priority_high_rounded, + color: Colors.white, + size: 14, + )), + }, + Text(text, softWrap: true, style: const TextStyle(fontSize: 14)), + ], + ), + ), + ), + duration: toastDuration, + onDismissed: dismiss, + ); + + _overlayEntry = OverlayEntry( + builder: (BuildContext context) => + Positioned(bottom: 60, left: 18, right: 18, child: toastChild)); + + _isVisible = true; + _overlayState!.insert(_overlayEntry!); + } + + static void dismiss() { + if (!_isVisible) { + return; + } + _isVisible = false; + _overlayEntry?.remove(); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_toast/t_toast_item.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_toast/t_toast_item.dart new file mode 100644 index 0000000000..0be209ba41 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_toast/t_toast_item.dart @@ -0,0 +1,9 @@ +import 't_toast_type.dart'; + +class TToastItem { + TToastItem({required this.text, required this.duration, required this.type}); + + final String text; + final Duration duration; + final TToastType type; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_toast/t_toast_type.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_toast/t_toast_type.dart new file mode 100644 index 0000000000..b01aaa8991 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_toast/t_toast_type.dart @@ -0,0 +1 @@ +enum TToastType { success, error, warning, info } diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_toast/t_toast_view.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_toast/t_toast_view.dart new file mode 100644 index 0000000000..6929e48bbb --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_toast/t_toast_view.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import '../../../../infra/animation/animation_utils.dart'; +import '../../../../infra/animation/dismissed_status_change_type.dart'; + +class TToastView extends StatefulWidget { + const TToastView( + {Key? key, + required this.duration, + required this.onDismissed, + this.fadeDuration = 500, + required this.child}) + : super(key: key); + + final Widget child; + final Duration duration; + final int fadeDuration; + final void Function() onDismissed; + + @override + TToastViewState createState() => TToastViewState(); +} + +class TToastViewState extends State + with SingleTickerProviderStateMixin { + AnimationController? _animationController; + late Animation _fadeAnimation; + AnimationStatus _animationStatus = AnimationStatus.dismissed; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: Duration(milliseconds: widget.fadeDuration), + )..addStatusListener((status) { + switch (AnimationUtils.detectDismissedStatusChange( + _animationStatus, status)) { + case DismissedStatusChangeType.becomeDismissed: + widget.onDismissed(); + case DismissedStatusChangeType.becomeNotDismissed: + Future.delayed(widget.duration, () { + if (mounted && + _animationController?.status == AnimationStatus.completed) { + _hideAnimation(); + } + }); + case DismissedStatusChangeType.noChange: + break; + } + _animationStatus = status; + }); + _fadeAnimation = + CurvedAnimation(parent: _animationController!, curve: Curves.easeIn); + super.initState(); + + _showAnimation(); + } + + @override + void deactivate() { + _animationController?.stop(); + super.deactivate(); + } + + @override + void dispose() { + _animationController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => FadeTransition( + opacity: _fadeAnimation, + child: Center( + child: Material( + color: Colors.transparent, + child: widget.child, + ), + ), + ); + + void _showAnimation() { + _animationController!.forward(); + } + + void _hideAnimation() { + _animationController!.reverse(); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_tooltip/t_tooltip.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_tooltip/t_tooltip.dart new file mode 100644 index 0000000000..c29d1b85a3 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_tooltip/t_tooltip.dart @@ -0,0 +1,588 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'app.dart'; +/// @docImport 'floating_action_button.dart'; +/// @docImport 'icon_button.dart'; +/// @docImport 'popup_menu.dart'; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../../../infra/animation/animation_utils.dart'; +import '../../../../infra/animation/dismissed_status_change_type.dart'; +import '../../../themes/index.dart'; + +// Modified: +// * Disappear instead of keeping displaying when hovering. + +class _ExclusiveMouseRegion extends MouseRegion { + const _ExclusiveMouseRegion({ + super.onEnter, + super.onExit, + super.child, + }); + + @override + _RenderExclusiveMouseRegion createRenderObject(BuildContext context) => + _RenderExclusiveMouseRegion( + onEnter: onEnter, + onExit: onExit, + ); +} + +class _RenderExclusiveMouseRegion extends RenderMouseRegion { + _RenderExclusiveMouseRegion({ + super.onEnter, + super.onExit, + }); + + static bool isOutermostMouseRegion = true; + static bool foundInnermostMouseRegion = false; + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + var isHit = false; + final outermost = isOutermostMouseRegion; + isOutermostMouseRegion = false; + if (size.contains(position)) { + isHit = + hitTestChildren(result, position: position) || hitTestSelf(position); + if ((isHit || behavior == HitTestBehavior.translucent) && + !foundInnermostMouseRegion) { + foundInnermostMouseRegion = true; + result.add(BoxHitTestEntry(this, position)); + } + } + + if (outermost) { + // The outermost region resets the global states. + isOutermostMouseRegion = true; + foundInnermostMouseRegion = false; + } + return isHit; + } +} + +class TTooltip extends StatefulWidget { + const TTooltip({ + super.key, + this.message, + this.richMessage, + this.height, + this.padding, + this.margin, + this.verticalOffset, + this.preferBelow, + this.excludeFromSemantics, + this.decoration, + this.textStyle, + this.textAlign, + this.waitDuration, + this.showDuration, + this.child, + }) : assert((message == null) != (richMessage == null), + 'Either `message` or `richMessage` must be specified'), + assert( + richMessage == null || textStyle == null, + 'If `richMessage` is specified, `textStyle` will have no effect. ' + 'If you wish to provide a `textStyle` for a rich tooltip, add the ' + '`textStyle` directly to the `richMessage` InlineSpan.', + ); + + /// The text to display in the tooltip. + /// + /// Only one of [message] and [richMessage] may be non-null. + final String? message; + + /// The rich text to display in the tooltip. + /// + /// Only one of [message] and [richMessage] may be non-null. + final InlineSpan? richMessage; + + /// The height of the tooltip's [child]. + /// + /// If the [child] is null, then this is the tooltip's intrinsic height. + final double? height; + + /// The amount of space by which to inset the tooltip's [child]. + /// + /// On mobile, defaults to 16.0 logical pixels horizontally and 4.0 vertically. + /// On desktop, defaults to 8.0 logical pixels horizontally and 4.0 vertically. + final EdgeInsetsGeometry? padding; + + /// The empty space that surrounds the tooltip. + /// + /// Defines the tooltip's outer [Container.margin]. By default, a + /// long tooltip will span the width of its window. If long enough, + /// a tooltip might also span the window's height. This property allows + /// one to define how much space the tooltip must be inset from the edges + /// of their display window. + /// + /// If this property is null, then [TooltipThemeData.margin] is used. + /// If [TooltipThemeData.margin] is also null, the default margin is + /// 0.0 logical pixels on all sides. + final EdgeInsetsGeometry? margin; + + /// The vertical gap between the widget and the displayed tooltip. + /// + /// When [preferBelow] is set to true and tooltips have sufficient space to + /// display themselves, this property defines how much vertical space + /// tooltips will position themselves under their corresponding widgets. + /// Otherwise, tooltips will position themselves above their corresponding + /// widgets with the given offset. + final double? verticalOffset; + + /// Whether the tooltip defaults to being displayed below the widget. + /// + /// Defaults to true. If there is insufficient space to display the tooltip in + /// the preferred direction, the tooltip will be displayed in the opposite + /// direction. + final bool? preferBelow; + + /// Whether the tooltip's [message] or [richMessage] should be excluded from + /// the semantics tree. + /// + /// Defaults to false. A tooltip will add a [Semantics] label that is set to + /// [TTooltip.message] if non-null, or the plain text value of + /// [TTooltip.richMessage] otherwise. Set this property to true if the app is + /// going to provide its own custom semantics label. + final bool? excludeFromSemantics; + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Specifies the tooltip's shape and background color. + /// + /// The tooltip shape defaults to a rounded rectangle with a border radius of + /// 4.0. Tooltips will also default to an opacity of 90% and with the color + /// [Colors.grey]\[700\] if [ThemeData.brightness] is [Brightness.dark], and + /// [Colors.white] if it is [Brightness.light]. + final Decoration? decoration; + + /// The style to use for the message of the tooltip. + /// + /// If null, the message's [TextStyle] will be determined based on + /// [ThemeData]. If [ThemeData.brightness] is set to [Brightness.dark], + /// [TextTheme.bodyMedium] of [ThemeData.textTheme] will be used with + /// [Colors.white]. Otherwise, if [ThemeData.brightness] is set to + /// [Brightness.light], [TextTheme.bodyMedium] of [ThemeData.textTheme] will be + /// used with [Colors.black]. + final TextStyle? textStyle; + + /// How the message of the tooltip is aligned horizontally. + /// + /// If this property is null, then [TooltipThemeData.textAlign] is used. + /// If [TooltipThemeData.textAlign] is also null, the default value is + /// [TextAlign.start]. + final TextAlign? textAlign; + + /// The length of time that a pointer must hover over a tooltip's widget + /// before the tooltip will be shown. + /// + /// Defaults to 0 milliseconds (tooltips are shown immediately upon hover). + final Duration? waitDuration; + + /// The length of time that the tooltip will be shown after a long press is + /// released (if triggerMode is [TooltipTriggerMode.longPress]) or a tap is + /// released (if triggerMode is [TooltipTriggerMode.tap]) or mouse pointer + /// exits the widget. + /// + /// Defaults to 1.5 seconds for long press and tap released or 0.1 seconds + /// for mouse pointer exits the widget. + final Duration? showDuration; + + @override + State createState() => TTooltipState(); +} + +class TTooltipState extends State + with SingleTickerProviderStateMixin { + static const double _defaultVerticalOffset = 24.0; + static const bool _defaultPreferBelow = true; + static const EdgeInsetsGeometry _defaultMargin = EdgeInsets.zero; + static const Duration _fadeInDuration = Duration(milliseconds: 150); + static const Duration _fadeOutDuration = Duration(milliseconds: 75); + static const Duration _defaultShowDuration = Duration(milliseconds: 1500); + static const Duration _defaultHoverShowDuration = Duration(milliseconds: 100); + static const Duration _defaultWaitDuration = Duration.zero; + static const bool _defaultExcludeFromSemantics = false; + static const TextAlign _defaultTextAlign = TextAlign.start; + + final OverlayPortalController _overlayController = OverlayPortalController(); + + // From InheritedWidgets + late bool _visible; + late TooltipThemeData _tooltipTheme; + + Duration get _hoverShowDuration => + widget.showDuration ?? + _tooltipTheme.showDuration ?? + _defaultHoverShowDuration; + + Duration get _waitDuration => + widget.waitDuration ?? _tooltipTheme.waitDuration ?? _defaultWaitDuration; + + /// The plain text message for this tooltip. + /// + /// This value will either come from [widget.message] or [widget.richMessage]. + String get _tooltipMessage => + widget.message ?? widget.richMessage!.toPlainText(); + + Timer? _timer; + AnimationController? _backingController; + + AnimationController get _controller => + _backingController ??= AnimationController( + duration: _fadeInDuration, + reverseDuration: _fadeOutDuration, + vsync: this, + )..addStatusListener(_handleStatusChanged); + + AnimationStatus _animationStatus = AnimationStatus.dismissed; + + void _handleStatusChanged(AnimationStatus status) { + assert(mounted); + switch ( + AnimationUtils.detectDismissedStatusChange(_animationStatus, status)) { + case DismissedStatusChangeType.becomeDismissed: + _overlayController.hide(); + case DismissedStatusChangeType.becomeNotDismissed: + _overlayController.show(); + SemanticsService.tooltip(_tooltipMessage); + case DismissedStatusChangeType.noChange: + break; + } + _animationStatus = status; + } + + void _scheduleShowTooltip({required Duration withDelay}) { + assert(mounted); + void show() { + assert(mounted); + if (!_visible) { + return; + } + _controller.forward(); + _timer?.cancel(); + _timer = null; + } + + assert( + !(_timer?.isActive ?? false) || + _controller.status != AnimationStatus.reverse, + 'timer must not be active when the tooltip is fading out', + ); + switch (_controller.status) { + case AnimationStatus.dismissed when withDelay.inMicroseconds > 0: + _timer?.cancel(); + _timer = Timer(withDelay, show); + // If the tooltip is already fading in or fully visible, skip the + // animation and show the tooltip immediately. + case AnimationStatus.dismissed: + case AnimationStatus.forward: + case AnimationStatus.reverse: + case AnimationStatus.completed: + show(); + } + } + + void _scheduleDismissTooltip({required Duration withDelay}) { + assert(mounted); + assert( + !(_timer?.isActive ?? false) || + _backingController?.status != AnimationStatus.reverse, + 'timer must not be active when the tooltip is fading out', + ); + + _timer?.cancel(); + _timer = null; + // Use _backingController instead of _controller to prevent the lazy getter + // from instaniating an AnimationController unnecessarily. + switch (_backingController?.status) { + case null: + case AnimationStatus.reverse: + case AnimationStatus.dismissed: + break; + // Dismiss when the tooltip is fading in: if there's a dismiss delay we'll + // allow the fade in animation to continue until the delay timer fires. + case AnimationStatus.forward: + case AnimationStatus.completed: + if (withDelay.inMicroseconds > 0) { + _timer = Timer(withDelay, _controller.reverse); + } else { + _controller.reverse(); + } + } + } + + @override + void initState() { + super.initState(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _visible = TooltipVisibility.of(context); + _tooltipTheme = TooltipTheme.of(context); + } + + // https://material.io/components/tooltips#specs + double _getDefaultTooltipHeight() => switch (Theme.of(context).platform) { + TargetPlatform.macOS || + TargetPlatform.linux || + TargetPlatform.windows => + 24.0, + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.iOS => + 32.0, + }; + + EdgeInsets _getDefaultPadding() => switch (Theme.of(context).platform) { + TargetPlatform.macOS || + TargetPlatform.linux || + TargetPlatform.windows => + const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.iOS => + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), + }; + + static double _getDefaultFontSize(TargetPlatform platform) => + switch (platform) { + TargetPlatform.macOS || + TargetPlatform.linux || + TargetPlatform.windows => + 12.0, + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.iOS => + 14.0, + }; + + Widget _buildTooltipOverlay(BuildContext context) { + final overlayState = Overlay.of(context, debugRequiredFor: widget); + final box = this.context.findRenderObject()! as RenderBox; + final target = box.localToGlobal( + box.size.center(Offset.zero), + ancestor: overlayState.context.findRenderObject(), + ); + + final (TextStyle defaultTextStyle, BoxDecoration defaultDecoration) = + switch (Theme.of(context)) { + ThemeData( + brightness: Brightness.dark, + :final TextTheme textTheme, + :final TargetPlatform platform + ) => + ( + textTheme.bodyMedium!.copyWith( + color: Colors.black, fontSize: _getDefaultFontSize(platform)), + BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: Sizes.borderRadiusCircular4), + ), + ThemeData( + brightness: Brightness.light, + :final TextTheme textTheme, + :final TargetPlatform platform + ) => + ( + textTheme.bodyMedium!.copyWith( + color: Colors.white, fontSize: _getDefaultFontSize(platform)), + BoxDecoration( + color: Colors.grey.shade700.withValues(alpha: 0.9), + borderRadius: Sizes.borderRadiusCircular4), + ), + }; + + final tooltipTheme = _tooltipTheme; + final overlayChild = _TooltipOverlay( + richMessage: widget.richMessage ?? TextSpan(text: widget.message), + height: + widget.height ?? tooltipTheme.height ?? _getDefaultTooltipHeight(), + padding: widget.padding ?? tooltipTheme.padding ?? _getDefaultPadding(), + margin: widget.margin ?? tooltipTheme.margin ?? _defaultMargin, + decoration: + widget.decoration ?? tooltipTheme.decoration ?? defaultDecoration, + textStyle: widget.textStyle ?? tooltipTheme.textStyle ?? defaultTextStyle, + textAlign: + widget.textAlign ?? tooltipTheme.textAlign ?? _defaultTextAlign, + animation: + CurvedAnimation(parent: _controller, curve: Curves.fastOutSlowIn), + target: target, + verticalOffset: widget.verticalOffset ?? + tooltipTheme.verticalOffset ?? + _defaultVerticalOffset, + preferBelow: + widget.preferBelow ?? tooltipTheme.preferBelow ?? _defaultPreferBelow, + ); + + return SelectionContainer.maybeOf(context) == null + ? overlayChild + : SelectionContainer.disabled(child: overlayChild); + } + + @override + void dispose() { + // _overlayController.hide(); + _timer?.cancel(); + _backingController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_tooltipMessage.isEmpty) { + return widget.child ?? const SizedBox.shrink(); + } + assert(debugCheckHasOverlay(context)); + final excludeFromSemantics = widget.excludeFromSemantics ?? + _tooltipTheme.excludeFromSemantics ?? + _defaultExcludeFromSemantics; + Widget result = Semantics( + tooltip: excludeFromSemantics ? null : _tooltipMessage, + child: widget.child, + ); + + // Only check for gestures if tooltip should be visible. + if (_visible) { + result = _ExclusiveMouseRegion( + onEnter: (_) { + _scheduleShowTooltip(withDelay: _waitDuration); + }, + onExit: (_) { + _scheduleDismissTooltip(withDelay: _hoverShowDuration); + }, + child: Listener( + onPointerDown: (_) { + _scheduleDismissTooltip(withDelay: _hoverShowDuration); + }, + child: result, + ), + ); + } + return OverlayPortal( + controller: _overlayController, + overlayChildBuilder: _buildTooltipOverlay, + child: result, + ); + } +} + +/// A delegate for computing the layout of a tooltip to be displayed above or +/// below a target specified in the global coordinate system. +class TTooltipPositionDelegate extends SingleChildLayoutDelegate { + /// Creates a delegate for computing the layout of a tooltip. + /// + /// The arguments must not be null. + TTooltipPositionDelegate({ + required this.target, + required this.verticalOffset, + required this.preferBelow, + }); + + /// The offset of the target the tooltip is positioned near in the global + /// coordinate system. + final Offset target; + + /// The amount of vertical distance between the target and the displayed + /// tooltip. + final double verticalOffset; + + /// Whether the tooltip is displayed below its widget by default. + /// + /// If there is insufficient space to display the tooltip in the preferred + /// direction, the tooltip will be displayed in the opposite direction. + final bool preferBelow; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) => + constraints.loosen(); + + @override + Offset getPositionForChild(Size size, Size childSize) => positionDependentBox( + size: size, + childSize: childSize, + target: target, + verticalOffset: verticalOffset, + preferBelow: preferBelow, + ); + + @override + bool shouldRelayout(TTooltipPositionDelegate oldDelegate) => + target != oldDelegate.target || + verticalOffset != oldDelegate.verticalOffset || + preferBelow != oldDelegate.preferBelow; +} + +class _TooltipOverlay extends StatelessWidget { + const _TooltipOverlay({ + required this.height, + required this.richMessage, + this.padding, + this.margin, + this.decoration, + this.textStyle, + this.textAlign, + required this.animation, + required this.target, + required this.verticalOffset, + required this.preferBelow, + }); + + final InlineSpan richMessage; + final double height; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final Decoration? decoration; + final TextStyle? textStyle; + final TextAlign? textAlign; + final Animation animation; + final Offset target; + final double verticalOffset; + final bool preferBelow; + + @override + Widget build(BuildContext context) => Positioned.fill( + bottom: MediaQuery.maybeViewInsetsOf(context)?.bottom ?? 0.0, + child: CustomSingleChildLayout( + delegate: TTooltipPositionDelegate( + target: target, + verticalOffset: verticalOffset, + preferBelow: preferBelow, + ), + child: FadeTransition( + opacity: animation, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: height), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyMedium!, + child: Container( + decoration: decoration, + padding: padding, + margin: margin, + child: Center( + widthFactor: 1.0, + heightFactor: 1.0, + child: Text.rich( + richMessage, + style: textStyle, + textAlign: textAlign, + ), + ), + ), + ), + ), + ), + ), + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/components/t_window_control_zone/t_window_control_zone.dart b/turms-chat-demo-flutter/lib/ui/desktop/components/t_window_control_zone/t_window_control_zone.dart new file mode 100644 index 0000000000..2ba77eb522 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/components/t_window_control_zone/t_window_control_zone.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../infra/window/window_utils.dart'; +import '../../../themes/index.dart'; + +class TWindowControlZone extends ConsumerWidget { + const TWindowControlZone( + {super.key, required this.toggleMaximizeOnDoubleTap, this.child}); + + final bool toggleMaximizeOnDoubleTap; + final Widget? child; + + @override + Widget build(BuildContext context, WidgetRef ref) => GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (details) { + WindowUtils.startDragging(); + }, + onDoubleTap: toggleMaximizeOnDoubleTap + ? () async { + final isWindowMaximized = ref.read(isWindowMaximizedViewModel); + if (isWindowMaximized) { + await WindowUtils.unmaximize(); + } else { + await WindowUtils.maximize(); + } + } + : null, + child: child ?? Sizes.sizedBoxInfinity, + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/app.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/app.dart new file mode 100644 index 0000000000..b598138ce0 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/app.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../../domain/user/view_models/logged_in_user_info_view_model.dart'; +import '../../../infra/app/app_config.dart'; +import '../../../infra/io/global_keyboard_listener.dart'; +import '../../../infra/window/window_utils.dart'; +import '../../l10n/app_localizations.dart'; +import '../../l10n/view_models/app_localizations_view_model.dart'; +import '../../l10n/view_models/system_locale_info_view_model.dart'; +import '../../themes/app_theme_extension.dart'; +import '../../themes/app_theme_view_model.dart'; +import '../../themes/sizes.dart'; +import '../components/t_dialog/t_dialog.dart'; +import 'home_page/home_page.dart'; +import 'login_page/login_page.dart'; + +ProviderContainer? _appContainer; +GlobalKey _navigatorKey = GlobalKey(); + +T readGlobalState(ProviderListenable provider) => + _appContainer!.read(provider); + +void popTopIfNameMatched(String name) { + final currentState = _navigatorKey.currentState; + if (currentState == null || !currentState.mounted) { + return; + } + String? currentPath; + currentState.popUntil((route) { + currentPath = route.settings.name; + return true; + }); + if (currentPath == name) { + currentState.pop(); + } +} + +class App extends ConsumerStatefulWidget { + App({super.key, required this.container}) { + _appContainer = container; + } + + final ProviderContainer container; + + @override + ConsumerState createState() => _AppState(); +} + +class _AppState extends ConsumerState with WindowListener { + bool _shouldDisplayLoginPage = true; + late bool _isWindowMaximized; + bool _isWindowSettingUp = false; + + @override + void initState() { + super.initState(); + windowManager.addListener(this); + // show windows in the next frame to ensure the UI is ready. + // Otherwise the UI will jitter because it is painting. + SchedulerBinding.instance.addPostFrameCallback((_) async { + await WindowUtils.show(); + }); + } + + @override + void dispose() { + windowManager.removeListener(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final locale = ref.watch(localeInfoViewModel).locale; + final themeData = ref.watch(themeViewModel); + final appLocalizations = ref.watch(appLocalizationsViewModel); + _isWindowMaximized = ref.watch(isWindowMaximizedViewModel); + + ref.listen(loggedInUserViewModel, + (previousLoggedInUser, currentLoggedInUser) { + final displayLoginPage = currentLoggedInUser == null; + if (_shouldDisplayLoginPage != displayLoginPage && !_isWindowSettingUp) { + _hideAndResize(displayLoginPage).then((value) { + _shouldDisplayLoginPage = displayLoginPage; + if (displayLoginPage) { + // todo + // final providerContainer = widget.container; + // for (final value in providerContainer.getAllProviderElements()) { + // providerContainer.invalidate(value.provider); + // } + } + setState(() {}); + SchedulerBinding.instance.addPostFrameCallback((_) { + WindowUtils.show(); + }); + }); + } + }); + return _buildView(themeData, locale, appLocalizations); + } + + @override + void onWindowFocus() { + setState(() {}); + } + + Future _hideAndResize(bool resizeForLoginPage) async { + _isWindowSettingUp = true; + // Hide first to resize and paint. + await WindowUtils.hide(); + // When hide() returns, the window may be hided or hiding (animation), + // so we wait to ensure the window is hided. + await WindowUtils.waitUntilInvisible(); + if (resizeForLoginPage) { + // Note that: We must set the min size first and then resize + // because if setting the min size and resizing in one call, + // the previous min size will restrict the current resize + // on window_manager (0.3.7). + await WindowUtils.setupWindow( + minimumSize: AppConfig.defaultWindowSizeForLoginScreen); + await WindowUtils.setupWindow( + size: AppConfig.defaultWindowSizeForLoginScreen, + backgroundColor: Colors.transparent); + } else { + await WindowUtils.setupWindow( + minimumSize: AppConfig.minWindowSizeForHomeScreen); + await WindowUtils.setupWindow( + size: AppConfig.defaultWindowSizeForHomeScreen, + resizable: true, + backgroundColor: Colors.white); + } + _isWindowSettingUp = false; + } + + bool _onKeyEvent(KeyEvent event) { + var hasRouteRemoved = false; + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + final currentState = _navigatorKey.currentState; + if (currentState != null && currentState.mounted) { + currentState.popUntil((route) { + // Check "!hasRouteRemoved" to only remove the first TDialog route. + if (!hasRouteRemoved && isTDialogRoute(route) && route.isActive) { + hasRouteRemoved = true; + return false; + } + return true; + }); + } + } + return hasRouteRemoved; + } + + Locale? _resolveLocale( + List? locales, Iterable supportedLocales) => + ref.read(localeInfoViewModel).locale; +} + +extension _AppView on _AppState { + Widget _buildView( + ThemeData themeData, Locale locale, AppLocalizations appLocalizations) { + final appThemeExtension = themeData.appThemeExtension; + final themeMode = appThemeExtension.themeMode; + return MaterialApp( + locale: locale, + debugShowCheckedModeBanner: false, + navigatorKey: _navigatorKey, + themeMode: themeMode, + theme: themeMode == ThemeMode.light ? themeData : null, + darkTheme: themeMode == ThemeMode.dark ? themeData : null, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + localeListResolutionCallback: _resolveLocale, + home: Material( + child: GlobalKeyboardListener( + onKeyEvent: _onKeyEvent, + child: _shouldDisplayLoginPage + ? const ClipRRect( + borderRadius: Sizes.borderRadiusCircular8, + child: LoginPage(), + ) + : ClipRRect( + borderRadius: _isWindowMaximized + ? Sizes.borderRadius0 + : Sizes.borderRadiusCircular8, + child: const HomePage(), + ), + ), + )); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/about_page/about_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/about_page/about_page.dart new file mode 100644 index 0000000000..0bdef476b1 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/about_page/about_page.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../../../../../infra/app/app_config.dart'; +import '../../../../../infra/assets/assets.gen.dart'; +import '../../../../../infra/github/github_client.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../themes/app_theme_extension.dart'; +import '../../../../themes/sizes.dart'; +import '../../../components/index.dart'; + +// Don't call "showAboutDialog" to avoid name conflict with +// the one in "flutter/lib/src/material/about.dart". +Future showAppAboutDialog(BuildContext context) => showCustomTDialog( + routeName: '/about-dialog', context: context, child: const AboutPage()); + +class AboutPage extends ConsumerStatefulWidget { + const AboutPage({super.key}); + + @override + ConsumerState createState() => _AboutPageState(); +} + +class _AboutPageState extends ConsumerState { + bool _isDownloading = false; + + @override + Widget build(BuildContext context) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + return _buildView(context.appThemeExtension, appLocalizations); + } + + Future _openGitHub() => + launchUrlString('https://github.com/turms-im/turms'); + + void _updateIsDownloading(bool isDownloading) { + if (_isDownloading != isDownloading) { + _isDownloading = isDownloading; + setState(() {}); + } + } +} + +extension _AboutPageView on _AboutPageState { + Widget _buildView(AppThemeExtension appThemeExtension, + AppLocalizations appLocalizations) => + SizedBox( + width: Sizes.aboutPageWidth, + height: Sizes.aboutPageHeight, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.only(top: 32, bottom: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SvgPicture.asset( + width: 320, + Assets.images.logo, + ), + Column(children: [ + TTextButton( + text: appLocalizations.update, + isLoading: _isDownloading, + onTap: () async { + _updateIsDownloading(true); + // TODO: Support installing automatically + String text; + try { + final file = await GithubUtils.downloadLatestApp(); + if (file == null) { + text = appLocalizations.alreadyLatestVersion; + } else { + // TODO: i10n + text = 'Downloaded: ${file.absolute.path}'; + } + } catch (e) { + text = + 'Failed to download latest application: ${e.toString()}'; + } + _updateIsDownloading(false); + unawaited(TToast.showToast(context, text)); + }) + ]), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${appLocalizations.version}: ${AppConfig.packageInfo.version}'), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, + children: [ + const Text('GitHub'), + TTextButton( + text: 'github.com/turms-im/turms', + containerColor: Colors.transparent, + containerColorHovered: Colors.transparent, + textStyle: appThemeExtension.linkTextStyle, + textStyleHovered: + appThemeExtension.linkHoveredTextStyle, + onTap: _openGitHub) + ], + ) + ], + ), + ), + const TTitleBar( + displayCloseOnly: true, + popOnCloseTapped: true, + ) + ], + ), + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/action_to_shortcut_view_model.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/action_to_shortcut_view_model.dart new file mode 100644 index 0000000000..440b826388 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/action_to_shortcut_view_model.dart @@ -0,0 +1,27 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../domain/user/view_models/user_settings_view_model.dart'; +import '../../../../infra/shortcut/shortcut.dart'; +import 'home_page_action.dart'; + +final actionToShortcutViewModel = + StateProvider>((ref) { + final actionToShortcut = {}; + final userSettings = ref.watch(userSettingsViewModel); + // We allow the user to unset shortcuts, + // so don't assign default shortcut here. + if (userSettings == null) { + return actionToShortcut; + } + actionToShortcut[HomePageAction.showChatPage] = + userSettings.shortcutShowChatPage; + actionToShortcut[HomePageAction.showContactsPage] = + userSettings.shortcutShowContactsPage; + actionToShortcut[HomePageAction.showFilesPage] = + userSettings.shortcutShowFilesPage; + actionToShortcut[HomePageAction.showSettingsDialog] = + userSettings.shortcutShowSettingsDialog; + actionToShortcut[HomePageAction.showAboutDialog] = + userSettings.shortcutShowAboutDialog; + return actionToShortcut; +}); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_page.dart new file mode 100644 index 0000000000..06ba2a1dd7 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_page.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../themes/index.dart'; +import '../../../components/index.dart'; +import '../../view_models/sub_navigation_rail_width_view_model.dart'; +import 'chat_session_pane/chat_session_pane.dart'; +import 'sub_navigation_rail/sub_navigation_rail.dart'; + +class ChatPage extends ConsumerStatefulWidget { + const ChatPage({super.key}); + + @override + ConsumerState createState() => _ChatPageState(); +} + +class _ChatPageState extends ConsumerState { + double _widthOnPointDown = 0; + + @override + Widget build(BuildContext context) { + final subNavigationRailWidth = ref.watch(subNavigationRailWidthViewModel); + final appThemeExtension = context.appThemeExtension; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + _buildSubNavigationRail(appThemeExtension, subNavigationRailWidth), + Positioned( + top: 0, + bottom: 0, + right: -Sizes.subNavigationRailDividerSize.padding.right, + child: TMovableVerticalDivider( + color: appThemeExtension.subNavigationRailDividerColor, + onMove: () { + _widthOnPointDown = subNavigationRailWidth; + }, + onMoved: (delta) { + ref + .read(subNavigationRailWidthViewModel.notifier) + .update(_widthOnPointDown + delta); + }, + ), + ), + ], + ), + Expanded(child: _buildChatSessionPane()), + ], + ); + } + + Widget _buildSubNavigationRail( + AppThemeExtension appThemeExtension, double subNavigationRailWidth) => + SizedBox( + width: subNavigationRailWidth, + child: const SubNavigationRail(), + ); + + Widget _buildChatSessionPane() => ChatSessionPane(); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/attachment.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/attachment.dart new file mode 100644 index 0000000000..e23ea309f8 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/attachment.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../../themes/index.dart'; +import '../../../../components/t_button/t_icon_button.dart'; + +class Attachment extends ConsumerStatefulWidget { + const Attachment( + {super.key, + required this.fileName, + required this.onRemoveAttachmentTapped}); + + final String fileName; + final void Function() onRemoveAttachmentTapped; + + @override + ConsumerState createState() => _AttachmentState(); +} + +class _AttachmentState extends ConsumerState { + bool _isContainerHovered = false; + bool _isCloseHovered = false; + + @override + Widget build(BuildContext context) { + final appThemeExtension = context.appThemeExtension; + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _isContainerHovered = true), + onExit: (_) => setState(() => _isContainerHovered = false), + child: GestureDetector( + onTap: () {}, + child: AnimatedContainer( + width: 300, + height: 48, + padding: Sizes.paddingH8, + duration: Durations.short2, + decoration: BoxDecoration( + color: _isContainerHovered + ? appThemeExtension.messageAttachmentHoveredColor + : appThemeExtension.messageAttachmentColor, + borderRadius: Sizes.borderRadiusCircular8, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + offset: const Offset(1, 1), + blurRadius: 2) + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Icon(Symbols.file_present_rounded, size: 28), + Sizes.sizedBoxW8, + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + widget.fileName, + textAlign: TextAlign.start, + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + ), + ], + ), + ), + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _isCloseHovered = true), + onExit: (_) => setState(() => _isCloseHovered = false), + child: TIconButton( + iconData: Symbols.close_rounded, + iconWeight: _isCloseHovered ? 700 : 400, + tooltip: + ref.watch(appLocalizationsViewModel).removeAttachment, + onTap: widget.onRemoveAttachmentTapped), + ) + ], + ), + ), + ), + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_details_drawer/chat_session_details_drawer.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_details_drawer/chat_session_details_drawer.dart new file mode 100644 index 0000000000..9a3b735ff0 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_details_drawer/chat_session_details_drawer.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../../../../domain/conversation/models/conversation.dart'; +import '../../../../../../../domain/conversation/services/conversation_service.dart'; +import '../../../../../../../domain/conversation/view_models/id_to_conversation_settings_view_model.dart'; +import '../../../../../../../domain/group/services/group_service.dart'; +import '../../../../../../../domain/user/models/group_member.dart'; +import '../../../../../../../domain/user/models/user.dart'; +import '../../../../../../../domain/user/view_models/logged_in_user_info_view_model.dart'; +import '../../../../../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../../../../../infra/core/comparable_utils.dart'; +import '../../../../../../../infra/ui/text_utils.dart'; +import '../../../../../../l10n/view_models/index.dart'; +import '../../../../../../themes/index.dart'; +import '../../../../../components/index.dart'; +import '../../../../../components/t_menu/t_context_menu.dart'; +import '../../../create_group_page/create_group_page.dart'; +import '../../../shared_components/user_profile_popup.dart'; + +part 'chat_session_details_group_conversation.dart'; + +part 'chat_session_details_user_conversation.dart'; + +class ChatSessionDetailsDrawer extends StatelessWidget { + const ChatSessionDetailsDrawer({super.key, required this.conversation}); + + final Conversation conversation; + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final appThemeExtension = theme.appThemeExtension; + return SizedBox( + width: Sizes.subNavigationRailWidth, + height: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + color: appThemeExtension.chatSessionDetailsDrawerBackgroundColor, + border: Border( + left: BorderSide(color: theme.dividerColor), + )), + child: Padding( + padding: const EdgeInsets.only(left: 16, top: 8, right: 16), + child: switch (conversation) { + final UserConversation c => + ChatSessionDetailsUserConversation(conversation: c), + final GroupConversation c => ChatSessionDetailsGroupConversation( + conversation: c, + ), + SystemConversation() => throw UnsupportedError(''), + }, + ), + ), + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_details_drawer/chat_session_details_group_conversation.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_details_drawer/chat_session_details_group_conversation.dart new file mode 100644 index 0000000000..9135562004 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_details_drawer/chat_session_details_group_conversation.dart @@ -0,0 +1,366 @@ +part of 'chat_session_details_drawer.dart'; + +const _avatarSize = TAvatarSize.small; +const _participantItemElementSpacing = 8.0; +const _participantItemSpacing = Sizes.sizedBoxH4; + +class ChatSessionDetailsGroupConversation extends ConsumerStatefulWidget { + const ChatSessionDetailsGroupConversation( + {super.key, required this.conversation}); + + final GroupConversation conversation; + + @override + ConsumerState createState() => + _ChatSessionDetailsGroupConversationState(); +} + +class _ChatSessionDetailsGroupConversationState + extends ConsumerState { + @override + Widget build(BuildContext context) { + final theme = context.theme; + final appThemeExtension = theme.appThemeExtension; + final appLocalizations = ref.watch(appLocalizationsViewModel); + final loggedInUser = ref.watch(loggedInUserViewModel)!; + final conversation = widget.conversation; + final conversationId = conversation.id; + final conversationSettings = + ref.watch(idToConversationSettingsViewModel)[conversationId]; + + const divider = THorizontalDivider(); + final contact = conversation.contact; + final intro = contact.intro; + + final locale = appLocalizations.localeName; + final members = contact.members; + final memberToIndex = + ComparableUtils.sortByStringsAsMap(locale, members, (m) => m.name); + members.sort( + (a, b) { + if (a.isAdmin) { + if (b.isAdmin) { + return memberToIndex[a]!.compareTo(memberToIndex[b]!); + } else { + return -1; + } + } else if (b.isAdmin) { + return 1; + } else { + return memberToIndex[a]!.compareTo(memberToIndex[b]!); + } + }, + ); + final isCurrentUserAdmin = + members.any((m) => m.isAdmin && m.userId == loggedInUser.userId); + + return Column( + children: [ + isCurrentUserAdmin + ? _ChatSessionDetailsGroupConversationName( + groupName: contact.name, + ) + : SelectionArea( + contextMenuBuilder: buildContextMenuForSelectableRegion, + child: Text( + contact.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + )), + if (intro.isNotBlank) ...[ + Sizes.sizedBoxH8, + SizedBox( + child: SelectionArea( + contextMenuBuilder: buildContextMenuForSelectableRegion, + child: Text( + intro, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: appThemeExtension.descriptionTextStyle, + ), + ), + ) + ], + Sizes.sizedBoxH8, + divider, + Sizes.sizedBoxH8, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(appLocalizations.pin), + TSwitch( + value: conversationSettings?.pinned ?? false, + onChanged: (value) { + ref.read(conversationServiceProvider)!.updateSettingPinned( + conversationId: conversationId, + newValue: value, + contact: contact, + ); + }, + ), + ], + ), + Sizes.sizedBoxH4, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(appLocalizations.muteNotifications), + TSwitch( + value: + conversationSettings?.enableNewMessageNotification ?? false, + onChanged: (value) { + ref + .read(conversationServiceProvider)! + .updateSettingEnableNewMessageNotification( + conversationId: conversationId, + newValue: value, + contact: contact, + ); + }, + ), + ], + ), + Sizes.sizedBoxH4, + divider, + Sizes.sizedBoxH8, + Expanded( + child: _ChatSessionDetailsGroupConversationMemberList(members)), + Sizes.sizedBoxH8, + divider, + SizedBox( + width: double.infinity, + child: TTextButton( + containerPadding: Sizes.paddingV8, + containerColor: Colors.transparent, + containerColorHovered: Colors.transparent, + text: appLocalizations.clearChatHistory, + textStyle: appThemeExtension.dangerTextStyle, + ), + ), + divider, + SizedBox( + width: double.infinity, + child: TTextButton( + containerPadding: Sizes.paddingV8, + containerColor: Colors.transparent, + containerColorHovered: Colors.transparent, + text: appLocalizations.leaveGroup, + textStyle: appThemeExtension.dangerTextStyle, + ), + ) + ], + ); + } +} + +class _ChatSessionDetailsGroupConversationMemberList + extends ConsumerStatefulWidget { + const _ChatSessionDetailsGroupConversationMemberList(this.members); + + final List members; + + @override + ConsumerState createState() => + _ChatSessionDetailsGroupConversationMemberListState(); +} + +class _ChatSessionDetailsGroupConversationMemberListState + extends ConsumerState<_ChatSessionDetailsGroupConversationMemberList> { + String _searchText = ''; + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final appThemeExtension = theme.appThemeExtension; + final appLocalizations = ref.watch(appLocalizationsViewModel); + + final isSearching = _searchText.isNotBlank; + final matchedMembers = isSearching + ? widget.members.expand<_StyledMember>((member) { + final nameTextSpans = TextUtils.highlightSearchText( + text: member.name, + searchText: _searchText, + searchTextStyle: appThemeExtension.highlightTextStyle); + if (nameTextSpans.length == 1) { + return []; + } + return [ + _StyledMember(member: member, nameTextSpans: nameTextSpans) + ]; + }).toList() + : widget.members + .map((member) => _StyledMember( + member: member, nameTextSpans: [TextSpan(text: member.name)])) + .toList(); + + final matchedMemberCount = matchedMembers.length; + final matchedMemberIdToIndex = { + for (var i = 0; i < matchedMemberCount; i++) + matchedMembers[i].member.userId: i + }; + return Column( + children: [ + TSearchBar( + hintText: appLocalizations.search, + onChanged: (value) { + _searchText = value; + setState(() {}); + }, + ), + Sizes.sizedBoxH8, + if (!isSearching) ...[ + _buildAddParticipantItem( + theme, + appLocalizations.addNewMember, + () { + // TODO + }, + ), + _participantItemSpacing + ], + Expanded( + child: isSearching && matchedMemberCount == 0 + ? Center( + child: Text( + appLocalizations.noMatchingGroupMembersFound, + style: appThemeExtension.descriptionTextStyle, + )) + : ListView.separated( + // Used to not overlay on the scrollbar + padding: const EdgeInsets.only(right: 12), + itemCount: matchedMemberCount, + findChildIndexCallback: (key) => + matchedMemberIdToIndex[(key as ValueKey).value], + itemBuilder: (context, index) { + final item = matchedMembers[index]; + final member = item.member; + return Row( + spacing: _participantItemElementSpacing, + children: [ + UserProfilePopup( + user: member, + popupAnchor: Alignment.topRight, + size: _avatarSize), + Expanded( + child: Text.rich( + TextSpan(children: item.nameTextSpans), + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + )), + if (member.isAdmin) + Icon( + Symbols.supervisor_account_rounded, + size: 22, + color: Colors.yellow.shade800, + ) + ], + ); + }, + separatorBuilder: (BuildContext context, int index) => + _participantItemSpacing, + )) + ], + ); + } +} + +Row _buildAddParticipantItem( + ThemeData theme, String hint, VoidCallback onTap) => + Row( + spacing: _participantItemElementSpacing, + children: [ + TIconButton( + iconData: Symbols.person_add_rounded, + containerSize: Size.square(_avatarSize.containerSize), + iconSize: 20, + iconColor: Colors.grey.shade600, + containerBorder: Border.all(color: theme.dividerColor), + containerBorderHovered: Border.all(color: theme.primaryColor), + onTap: onTap, + ), + Flexible(child: Text(hint)) + ], + ); + +class _ChatSessionDetailsGroupConversationName extends ConsumerStatefulWidget { + const _ChatSessionDetailsGroupConversationName({required this.groupName}); + + final String groupName; + + @override + ConsumerState<_ChatSessionDetailsGroupConversationName> createState() => + _ChatSessionDetailsGroupConversationNameState(); +} + +class _ChatSessionDetailsGroupConversationNameState + extends ConsumerState<_ChatSessionDetailsGroupConversationName> { + TextEditingController? _textEditingController; + bool _editingGroupName = false; + bool _isHovered = false; + + @override + void dispose() { + _textEditingController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => _editingGroupName + ? TTextField( + textEditingController: _textEditingController!, + autofocus: true, + onSubmitted: (value) async { + if (value.isBlank || value == widget.groupName) { + _editingGroupName = false; + setState(() {}); + return; + } + unawaited(ref.read(groupServiceProvider)!.updateGroupName(value)); + _editingGroupName = false; + setState(() {}); + }, + ) + : MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: Row( + children: [ + Flexible( + child: SelectionArea( + contextMenuBuilder: buildContextMenuForSelectableRegion, + child: Text( + widget.groupName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ))), + if (_isHovered) + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + final name = widget.groupName; + _textEditingController ??= + TextEditingController(text: name) + ..selection = TextSelection( + baseOffset: 0, extentOffset: name.length); + _editingGroupName = true; + setState(() {}); + }, + child: const Icon( + Symbols.edit_rounded, + size: 18, + ), + ), + ) + ], + ), + ); +} + +class _StyledMember { + const _StyledMember({required this.member, required this.nameTextSpans}); + + final GroupMember member; + final List nameTextSpans; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_details_drawer/chat_session_details_user_conversation.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_details_drawer/chat_session_details_user_conversation.dart new file mode 100644 index 0000000000..9db8796903 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_details_drawer/chat_session_details_user_conversation.dart @@ -0,0 +1,106 @@ +part of 'chat_session_details_drawer.dart'; + +class ChatSessionDetailsUserConversation extends ConsumerStatefulWidget { + const ChatSessionDetailsUserConversation( + {super.key, required this.conversation}); + + final UserConversation conversation; + + @override + ConsumerState createState() => + _ChatSessionDetailsUserConversationState(); +} + +class _ChatSessionDetailsUserConversationState + extends ConsumerState { + @override + Widget build(BuildContext context) { + final theme = context.theme; + final appLocalizations = ref.watch(appLocalizationsViewModel); + final loggedInUser = ref.watch(loggedInUserViewModel)!; + final conversation = widget.conversation; + final conversationId = conversation.id; + final conversationSettings = + ref.watch(idToConversationSettingsViewModel)[conversationId]; + const divider = THorizontalDivider(); + final contact = conversation.contact; + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(appLocalizations.pin), + TSwitch( + value: conversationSettings?.pinned ?? false, + onChanged: (value) { + ref.read(conversationServiceProvider)!.updateSettingPinned( + conversationId: conversationId, + newValue: value, + contact: contact, + ); + }, + ), + ], + ), + Sizes.sizedBoxH4, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(appLocalizations.muteNotifications), + TSwitch( + value: + conversationSettings?.enableNewMessageNotification ?? false, + onChanged: (value) { + ref + .read(conversationServiceProvider)! + .updateSettingEnableNewMessageNotification( + conversationId: conversationId, + newValue: value, + contact: contact, + ); + }, + ), + ], + ), + Sizes.sizedBoxH4, + divider, + Sizes.sizedBoxH8, + _buildAddParticipantItem(theme, appLocalizations.createGroup, () { + showCreateGroupDialog( + context: context, selectedUserIds: {contact.userId}); + }), + _participantItemSpacing, + _buildParticipantItem(loggedInUser), + _participantItemSpacing, + _buildParticipantItem(contact), + const Spacer(), + divider, + SizedBox( + width: double.infinity, + child: TTextButton( + containerPadding: Sizes.paddingV8, + containerColor: Colors.transparent, + containerColorHovered: Colors.transparent, + text: appLocalizations.clearChatHistory, + textStyle: context.appThemeExtension.dangerTextStyle, + ), + ) + ], + ); + } + + Row _buildParticipantItem(User user) => Row( + spacing: _participantItemElementSpacing, + children: [ + UserProfilePopup( + user: user, popupAnchor: Alignment.topRight, size: _avatarSize), + Expanded( + child: Text( + user.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + )), + ], + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane.dart new file mode 100644 index 0000000000..4ab1611bee --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane.dart @@ -0,0 +1,117 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../themes/index.dart'; +import '../../../../components/index.dart'; +import '../view_models/selected_conversation_view_model.dart'; +import 'chat_session_details_drawer/chat_session_details_drawer.dart'; +import 'chat_session_pane_body.dart'; +import 'chat_session_pane_footer/chat_session_pane_footer.dart'; +import 'chat_session_pane_header.dart'; + +const chatSessionDetailsDrawerGroupId = 'chatSessionDetailsDrawer'; + +class ChatSessionPane extends ConsumerWidget { + ChatSessionPane({super.key}); + + final TDrawerController drawerController = TDrawerController(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appThemeExtension = context.appThemeExtension; + final selectedConversation = ref.watch(selectedConversationViewModel); + if (selectedConversation == null) { + return const TWindowControlZone( + toggleMaximizeOnDoubleTap: true, child: TEmpty()); + } + return ColoredBox( + color: appThemeExtension.homePageBackgroundColor, + child: Column( + children: [ + DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: appThemeExtension.chatSessionPaneDividerColor))), + child: Padding( + // Note that we need the padding to make the border + // the same height with the header of the sub navigation rail. + padding: const EdgeInsets.only(bottom: 1), + child: SizedBox( + height: Sizes.homePageHeaderHeight, + child: ChatSessionPaneHeader( + drawerController: drawerController, + // Don't show drawer for the file transfer session. + supportDrawer: !selectedConversation.contact.isFileTransfer, + ), + ), + ), + ), + Expanded( + child: Stack( + children: [ + Column( + children: [ + Expanded(child: ChatSessionPaneBody(selectedConversation)), + const _ChatSessionPaneFooter(), + ], + ), + if (!selectedConversation.contact.isFileTransfer) + TapRegion( + groupId: chatSessionDetailsDrawerGroupId, + onTapOutside: (event) { + drawerController.hide?.call(); + }, + child: RepaintBoundary( + child: TDrawer( + controller: drawerController, + child: ChatSessionDetailsDrawer( + conversation: selectedConversation, + )), + )), + ], + ), + ), + ], + ), + ); + } +} + +class _ChatSessionPaneFooter extends StatefulWidget { + const _ChatSessionPaneFooter(); + + @override + State<_ChatSessionPaneFooter> createState() => _ChatSessionPaneFooterState(); +} + +class _ChatSessionPaneFooterState extends State<_ChatSessionPaneFooter> { + double _height = 240.0; + double _heightOnPointDown = 0; + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + TMovableHorizontalDivider( + onMove: () { + _heightOnPointDown = _height; + }, + onMoved: (delta) { + final newHeight = + (_heightOnPointDown - delta).clamp(130, 500).roundToDouble(); + if (newHeight != _height) { + _height = newHeight; + setState(() {}); + } + }, + ), + ConstrainedBox( + constraints: BoxConstraints.tightFor(height: _height), + child: const ChatSessionPaneFooter(), + ) + ], + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane_body.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane_body.dart new file mode 100644 index 0000000000..25eceb4930 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane_body.dart @@ -0,0 +1,399 @@ +import 'package:collection/collection.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../../../../domain/conversation/models/conversation.dart'; +import '../../../../../../domain/message/models/message_delivery_status.dart'; +import '../../../../../../domain/message/models/message_group.dart'; +import '../../../../../../domain/message/models/message_type.dart'; +import '../../../../../../domain/message/services/message_service.dart'; +import '../../../../../../domain/user/models/user.dart'; +import '../../../../../../domain/user/services/user_service.dart'; +import '../../../../../../domain/user/view_models/logged_in_user_info_view_model.dart'; +import '../../../../../../infra/random/random_utils.dart'; +import '../../../../../l10n/app_localizations.dart'; +import '../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../../l10n/view_models/date_format_view_models.dart'; +import '../../../../../themes/index.dart'; +import '../../../../components/index.dart'; +import '../../../../components/t_menu/t_context_menu.dart'; +import '../view_models/selected_conversation_view_model.dart'; +import 'message.dart'; +import 'message_bubble/message_bubble.dart'; + +const _loadMoreDistanceThreshold = 8; + +class ChatSessionPaneBody extends ConsumerStatefulWidget { + const ChatSessionPaneBody(this.selectedConversation, {super.key}); + + final Conversation selectedConversation; + + @override + ConsumerState createState() => + _ChatSessionPaneBodyState(); +} + +const _chatSessionItemLoadingIndicatorId = Int64.MIN_VALUE; +const _chatSessionItemLoadingIndicatorKey = + ValueKey(_chatSessionItemLoadingIndicatorId); + +class _ChatSessionPaneBodyState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + + bool _isLoading = false; + bool _isAllMessagesLoaded = false; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_loadMoreIfScrollToTop); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final appThemeExtension = context.appThemeExtension; + final selectedConversation = widget.selectedConversation; + final messages = selectedConversation.messages; + return ColoredBox( + color: appThemeExtension.homePageBackgroundColor, + child: messages.isEmpty + ? null + : _buildMessageBubbles(selectedConversation)); + } + + void _loadMoreIfScrollToTop() { + final position = _scrollController.position; + if (!_isAllMessagesLoaded && + position.maxScrollExtent - position.pixels < + _loadMoreDistanceThreshold) { + _loadMoreMessages(); + } + } + + /// We don't use sealed classes to limit possible values + /// for better performance (don't need to create new objects). + List<_ChatSessionItem> _generateItems(List messages) { + final items = <_ChatSessionItem>[const _ChatSessionItemLoadingIndicator()]; + final messageCount = messages.length; + if (messageCount == 0) { + return items; + } + ChatMessage? lastMessage; + MessageGroup? lastMessageGroup; + assert(() { + final sortedMessages = List.from(messages) + ..sort( + (a, b) { + final result = a.timestamp.compareTo(b.timestamp); + if (result == 0) { + // Used to ensure a stable sort. + return a.messageId.compareTo(b.messageId); + } + return result; + }, + ); + return const ListEquality().equals(sortedMessages, messages); + }(), + "The messages should have been sorted by date so that we don't need to sort them again and again when building the list"); + final lastMessageIndex = messageCount - 1; + for (var i = 0; i < messageCount; i++) { + final message = messages[i]; + final timestamp = message.timestamp; + if (lastMessage == null) { + items.add(_ChatSessionItemDaySeparator(timestamp)); + if (i == lastMessageIndex) { + items.add(_ChatSessionItemMessage(message)); + return items; + } + } else { + final lastMessageTimestamp = lastMessage.timestamp; + assert(lastMessageTimestamp.timeZoneName == timestamp.timeZoneName, + 'The timestamp of messages should have the same time zone name'); + if (DateUtils.isSameDay(lastMessageTimestamp, timestamp)) { + if (lastMessage.type == MessageType.text && + message.type == MessageType.text && + lastMessage.senderId == message.senderId && + lastMessageTimestamp.hour == timestamp.hour && + lastMessageTimestamp.minute == timestamp.minute) { + if (lastMessageGroup == null) { + lastMessageGroup = MessageGroup([lastMessage, message]); + items.add(_ChatSessionItemMessageGroup(lastMessageGroup)); + } else { + lastMessageGroup.addMessage(message); + } + if (i == lastMessageIndex) { + return items; + } + } else { + if (lastMessageGroup == null || + lastMessage.messageId != + lastMessageGroup.messages.last.messageId) { + items.add(_ChatSessionItemMessage(lastMessage)); + } + lastMessageGroup = null; + } + } else { + items.add(_ChatSessionItemDaySeparator(timestamp)); + if (lastMessageGroup == null || + lastMessage.messageId != + lastMessageGroup.messages.last.messageId) { + items.add(_ChatSessionItemMessage(lastMessage)); + } + lastMessageGroup = null; + } + if (i == lastMessageIndex) { + if (lastMessageGroup == null || + message.messageId != lastMessageGroup.messages.last.messageId) { + items.add(_ChatSessionItemMessage(message)); + } + return items; + } + } + lastMessage = message; + } + throw AssertionError('Unreachable code reached'); + } + + Widget _buildMessageBubbles(Conversation conversation) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + final loggedInUser = ref.watch(loggedInUserViewModel)!; + final dateFormat = ref.watch(dateFormatViewModel_yMd); + final items = _generateItems(conversation.messages); + final itemCount = items.length; + final itemIdToIndex = {for (var i = 0; i < itemCount; i++) items[i].id: i}; + final yesterday = DateTime.now().subtract(const Duration(days: 1)); + return TSelectionArea( + contextMenuBuilder: buildContextMenuForTSelectableRegion, + child: TSelectionContainer( + visible: false, + child: LayoutBuilder( + // TODO: Use CustomScrollView + builder: (context, constraints) => ListView.builder( + padding: Sizes.paddingV8H16, + controller: _scrollController, + reverse: true, + itemCount: itemCount, + findChildIndexCallback: (key) { + final index = itemIdToIndex[(key as ValueKey).value]; + if (index == null) { + return null; + } + return _getActualIndex(itemCount, index); + }, + itemBuilder: (context, index) { + final actualIndex = _getActualIndex(itemCount, index); + final item = items[actualIndex]; + return switch (item) { + _ChatSessionItemLoadingIndicator() => _buildLoadingIndicator(), + _ChatSessionItemDaySeparator(:final datetime) => + _buildDaySeparator( + item, yesterday, datetime, appLocalizations, dateFormat), + _ChatSessionItemMessage(:final message) => _buildMessages( + conversation, + [message], + loggedInUser, + item, + constraints.maxWidth), + _ChatSessionItemMessageGroup(:final messageGroup) => + _buildMessages(conversation, messageGroup.messages, + loggedInUser, item, constraints.maxWidth) + }; + }, + ), + ), + ), + ); + } + + int _getActualIndex(int itemCount, int index) => itemCount - index - 1; + + SingleChildRenderObjectWidget _buildLoadingIndicator() { + if (_isLoading) { + return const Padding( + key: _chatSessionItemLoadingIndicatorKey, + padding: Sizes.paddingV8, + child: CupertinoActivityIndicator(radius: 8), + ); + } else if (_isAllMessagesLoaded) { + return const Center( + key: _chatSessionItemLoadingIndicatorKey, + child: Text('No more messages'), + ); + } else { + // TODO + return const Center( + key: _chatSessionItemLoadingIndicatorKey, + child: Text('Load More Messages'), + ); + } + } + + Padding _buildDaySeparator( + _ChatSessionItemDaySeparator item, + DateTime yesterday, + DateTime datetime, + AppLocalizations appLocalizations, + DateFormat dateFormat) => + Padding( + key: ValueKey(item.id), + padding: Sizes.paddingV16, + child: Center( + child: DecoratedBox( + decoration: const BoxDecoration( + color: Color.fromARGB(255, 218, 218, 218), + borderRadius: Sizes.borderRadiusCircular4), + child: Padding( + padding: Sizes.paddingV2H4, + child: Text( + DateUtils.isSameDay(yesterday, datetime) + ? appLocalizations.yesterday + : dateFormat.format(datetime), + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ), + ), + ); + + MessageBubble _buildMessages( + Conversation conversation, + List messages, + User loggedInUser, + _ChatSessionItem item, + double availableWidth) { + final message = messages.first; + final user = _getMessageSender(conversation, message, loggedInUser); + return MessageBubble( + key: ValueKey(item.id), + currentUser: loggedInUser, + sender: user, + messages: messages, + availableWidth: availableWidth, + onRetry: message.sentByMe + ? () async { + await _sendMessage(conversation, message); + } + : null, + ); + } + + Future _sendMessage( + Conversation conversation, ChatMessage message) async { + final now = DateTime.now(); + final previousMessageId = message.messageId; + final fakeMessageId = -RandomUtils.nextUniquePositiveInt64(); + // Note that: the timestamp may be different from the one the recipients received. + // TODO: Use the server time for reference, + // especially when the device time is not correct. + final DateTime tempTimestamp; + final lastMessageTimestamp = conversation.messages.lastOrNull?.timestamp; + if (lastMessageTimestamp == null || + lastMessageTimestamp.compareTo(now) < 0) { + tempTimestamp = now; + } else { + tempTimestamp = lastMessageTimestamp.add(const Duration(milliseconds: 1)); + } + message = message.copyWith( + messageId: fakeMessageId, + timestamp: tempTimestamp, + status: MessageDeliveryStatus.delivering); + final selectedConversationController = + ref.read(selectedConversationViewModel.notifier) + ..removeMessage(previousMessageId) + ..addMessage(message); + setState(() {}); + + final sentMessage = await ref + .read(messageServiceProvider)! + .sendMessage(message.text!, message); + + // TODO: handle the case when the controller has already been changed. + selectedConversationController.replaceMessage( + fakeMessageId, + message.copyWith( + messageId: sentMessage.messageId, + status: sentMessage.status, + // Note that this will update the timestamp UI of the message + // if the received timestamp is different from the fake one, + // which is expected to ensure the timestamp is consistent with the server and recipients. + timestamp: sentMessage.timestamp)); + } + + User _getMessageSender( + Conversation conversation, ChatMessage message, User loggedInUser) { + if (message.sentByMe) { + return loggedInUser; + } else if (conversation is UserConversation) { + return conversation.contact; + } else { + return ref.read(userServiceProvider)!.queryUsers(message.senderId); + } + } + + Future _loadMoreMessages() async { + if (_isLoading) { + return; + } + _isLoading = true; + setState(() {}); + final messages = + await ref.read(messageServiceProvider)!.queryMoreMessages(); + // TODO: add messages to the conversation. + _isLoading = false; + if (messages.isEmpty) { + _isAllMessagesLoaded = true; + } + if (!mounted) { + return; + } + setState(() {}); + } + + void _scrollToBottom() { + _scrollController.animateTo( + _scrollController.position.minScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } +} + +sealed class _ChatSessionItem { + const _ChatSessionItem(this.id); + + final Int64 id; +} + +class _ChatSessionItemLoadingIndicator extends _ChatSessionItem { + const _ChatSessionItemLoadingIndicator() + : super(_chatSessionItemLoadingIndicatorId); +} + +class _ChatSessionItemDaySeparator extends _ChatSessionItem { + _ChatSessionItemDaySeparator(this.datetime) + : super(Int64(-datetime.millisecondsSinceEpoch)); + + final DateTime datetime; +} + +class _ChatSessionItemMessage extends _ChatSessionItem { + _ChatSessionItemMessage(this.message) : super(message.messageId); + + final ChatMessage message; +} + +class _ChatSessionItemMessageGroup extends _ChatSessionItem { + _ChatSessionItemMessageGroup(this.messageGroup) + : super((-messageGroup.messages.first.messageId) | (Int64(1) << 62)); + + final MessageGroup messageGroup; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane_footer/chat_session_pane_footer.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane_footer/chat_session_pane_footer.dart new file mode 100644 index 0000000000..b4dad0fb76 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane_footer/chat_session_pane_footer.dart @@ -0,0 +1,447 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dotted_border/dotted_border.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:super_clipboard/super_clipboard.dart'; +import 'package:super_drag_and_drop/super_drag_and_drop.dart'; + +import '../../../../../../../domain/conversation/models/conversation.dart'; +import '../../../../../../../domain/message/models/message_delivery_status.dart'; +import '../../../../../../../domain/message/services/message_service.dart'; +import '../../../../../../../domain/user/view_models/logged_in_user_info_view_model.dart'; +import '../../../../../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../../../../../infra/io/data_reader_file_adaptor.dart'; +import '../../../../../../../infra/io/file_utils.dart'; +import '../../../../../../../infra/logging/logger.dart'; +import '../../../../../../../infra/random/random_utils.dart'; +import '../../../../../../l10n/app_localizations.dart'; +import '../../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../../../themes/app_theme_extension.dart'; +import '../../../../../../themes/sizes.dart'; +import '../../../../../components/giphy/client/models/gif.dart'; +import '../../../../../components/index.dart'; +import '../../view_models/selected_conversation_view_model.dart'; +import '../attachment.dart'; +import '../message.dart'; +import '../single_chat_history_page/single_chat_history_page.dart'; +import '../sticker_picker/sticker_picker.dart'; +import 'message_editor.dart'; + +class ChatSessionPaneFooter extends ConsumerStatefulWidget { + const ChatSessionPaneFooter({super.key}); + + @override + ConsumerState createState() => + _ChatSessionPaneFooterState(); +} + +class _ChatSessionPaneFooterState extends ConsumerState { + final List _localFiles = []; + bool _dragging = false; + + late EmojiTextEditingController _editorController; + late FocusNode _editorFocusNode; + + late TPopupController _stickerPickerPopupController; + + Conversation? _conversation; + + @override + void initState() { + super.initState(); + _editorController = EmojiTextEditingController() + ..addListener(() { + // The send button will be enabled when the text is not empty, + // so we need to update the state. + setState(() {}); + }); + _editorFocusNode = FocusNode(); + _stickerPickerPopupController = TPopupController(); + } + + @override + void dispose() { + _editorController.dispose(); + _editorFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final appThemeExtension = theme.appThemeExtension; + final appLocalizations = ref.watch(appLocalizationsViewModel); + final newConversation = ref.watch(selectedConversationViewModel); + final currentConversation = _conversation; + if (newConversation?.id != currentConversation?.id) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (currentConversation != null) { + final previousDraft = currentConversation.draft; + final draft = _getEditorDocument(); + if (draft.isNotEmpty && draft != previousDraft) { + currentConversation.draft = draft; + } else { + currentConversation.draft = null; + } + ref.read(selectedConversationViewModel.notifier).notifyListeners(); + } + final currentDraft = newConversation?.draft; + if (currentDraft?.isBlank ?? true) { + _editorController.text = ''; + } else { + _editorController.text = currentDraft!; + } + _editorFocusNode.requestFocus(); + setState(() {}); + }); + _conversation = newConversation; + } + return _buildView(theme, appThemeExtension, appLocalizations); + } + + String _getEditorDocument() => _editorController.text.trim(); + + bool _tryAddNewFile(List newFiles) { + var hasNewFile = false; + for (final newFile in newFiles) { + // Exclude directory (The size of directory is null). + if (newFile.fileSize == null) { + continue; + } + if (!_localFiles.any((existingFile) => + existingFile.fileName == newFile.fileName && + existingFile.fileSize == newFile.fileSize)) { + hasNewFile = true; + _localFiles.add(newFile); + } + } + return hasNewFile; + } + + void _onDropEnter() { + _dragging = true; + setState(() {}); + } + + void _onDropLeave() { + _dragging = false; + setState(() {}); + } + + DropOperation _onDropOver(DropOverEvent event) { + if (event.session.allowedOperations.contains(DropOperation.copy)) { + return DropOperation.copy; + } else { + return DropOperation.none; + } + } + + Future _onPerformDrop(PerformDropEvent event) async { + final futures = event.session.items.map((item) { + final completer = Completer(); + item.dataReader! + .getFile(null, completer.complete, onError: completer.completeError); + return completer.future; + }); + final newFiles = await Future.wait(futures); + if (_tryAddNewFile(newFiles)) { + setState(() {}); + } + } + + void _insertEmoji(String emoji) { + final selection = _editorController.selection; + final start = selection.start; + final end = selection.end; + final text = _editorController.text; + String prefix; + if (start < 0) { + prefix = text + emoji; + _editorController.text = prefix; + } else { + prefix = start == 0 ? emoji : text.substring(0, start) + emoji; + if (end >= text.length) { + _editorController.text = prefix; + } else { + _editorController.text = prefix + text.substring(end); + } + } + _editorController.selection = + TextSelection.collapsed(offset: prefix.length); + + _editorFocusNode.requestFocus(); + _stickerPickerPopupController.hidePopover?.call(); + setState(() {}); + } + + Future _sendMessage([String? msg]) async { + final text = msg ?? _getEditorDocument(); + if (text.isBlank) { + return; + } + final now = DateTime.now(); + final fakeMessageId = -RandomUtils.nextUniquePositiveInt64(); + // Note that: the timestamp may be different from the one the recipients received. + // TODO: Use the server time for reference, + // especially when the device time is not correct. + final DateTime tempTimestamp; + final lastMessageTimestamp = _conversation?.messages.lastOrNull?.timestamp; + if (lastMessageTimestamp == null || + lastMessageTimestamp.compareTo(now) < 0) { + tempTimestamp = now; + } else { + tempTimestamp = lastMessageTimestamp.add(const Duration(milliseconds: 1)); + } + final isGroupMessage = _conversation is GroupConversation; + final loggedInUserId = ref.read(loggedInUserViewModel)!.userId; + final (groupId, recipientId) = switch (_conversation!) { + GroupConversation c => (c.contact.groupId, null), + UserConversation c => (null, c.contact.userId), + SystemConversation c => (null, loggedInUserId) + }; + final message = ChatMessage.parse( + text: text, + messageId: fakeMessageId, + groupId: groupId, + recipientId: recipientId, + senderId: loggedInUserId, + sentByMe: true, + isGroupMessage: isGroupMessage, + timestamp: tempTimestamp, + status: MessageDeliveryStatus.delivering); + final selectedConversationController = + ref.read(selectedConversationViewModel.notifier)..addMessage(message); + _editorController.text = ''; + setState(() {}); + + final sentMessage = + await ref.read(messageServiceProvider)!.sendMessage(text, message); + + // TODO: handle the case when the controller has already been changed. + selectedConversationController.replaceMessage( + fakeMessageId, + message.copyWith( + messageId: sentMessage.messageId, + status: sentMessage.status, + // Note that this will update the timestamp UI of the message + // if the received timestamp is different from the fake one, + // which is expected to ensure the timestamp is consistent with the server and recipients. + timestamp: sentMessage.timestamp)); + } + + void _sendImage(AppLocalizations appLocalizations, String originalUrl, + String thumbnailUrl, int width, int height) { + try { + final originalUri = Uri.parse(originalUrl); + originalUrl = originalUri.origin + originalUri.path; + } catch (e) { + TToast.showToast(context, appLocalizations.failedToSendImageInvalidUrl); + logger.error( + 'Failed to send image. The original URL is invalid: $originalUrl', e); + return; + } + try { + final thumbnailUri = Uri.parse(thumbnailUrl); + thumbnailUrl = thumbnailUri.origin + thumbnailUri.path; + } catch (e) { + TToast.showToast(context, appLocalizations.failedToSendImageInvalidUrl); + logger.error( + 'Failed to send image. The thumbnail URL is invalid: $thumbnailUrl', + e); + return; + } + final text = MessageService.encodeImageMessage( + originalUrl: originalUrl, + thumbnailUrl: thumbnailUrl, + width: width, + height: height); + _sendMessage(text); + } + + void _removeFiles(DataReaderFile file) { + _localFiles.remove(file); + setState(() {}); + } + + Future _pickFile() async { + final result = await FileUtils.pickFile(); + if (result == null) { + return; + } + final path = result.files.singleOrNull?.path; + if (path == null) { + return; + } + final file = File(path); + _tryAddNewFile([DataReaderFileValueAdapter(file)]); + setState(() {}); + } +} + +extension _ChatSessionPaneFooterView on _ChatSessionPaneFooterState { + Widget _buildView(ThemeData theme, AppThemeExtension appThemeExtension, + AppLocalizations appLocalizations) => + Stack( + children: [ + DropRegion( + formats: Formats.standardFormats, + onDropOver: _onDropOver, + onDropEnter: (event) { + _onDropEnter(); + }, + onDropLeave: (event) { + _onDropLeave(); + }, + onPerformDrop: _onPerformDrop, + child: Padding( + padding: Sizes.paddingV8H16, + child: _buildEditor(theme, appThemeExtension, appLocalizations), + )), + _buildDropZoneMask(theme, appLocalizations) + ], + ); + + Widget _buildEditor(ThemeData theme, AppThemeExtension appThemeExtension, + AppLocalizations appLocalizations) => + Column( + children: [ + Expanded( + child: ColoredBox( + color: appThemeExtension.homePageBackgroundColor, + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.enter): _sendMessage + }, + child: MessageEditor( + controller: _editorController, + conversation: _conversation!, + autofocus: true, + focusNode: _editorFocusNode, + contentPadding: const EdgeInsets.only(top: 8), + ), + ), + ), + ), + if (_localFiles.isNotEmpty) + Align( + alignment: Alignment.topLeft, + child: Wrap( + runSpacing: 4, + spacing: 4, + children: _localFiles + .map((file) => Attachment( + fileName: file.fileName ?? '', + onRemoveAttachmentTapped: () { + _removeFiles(file); + }, + )) + .toList(growable: false), + ), + ), + const SizedBox( + height: 12, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + TPopup( + controller: _stickerPickerPopupController, + targetAnchor: Alignment.topCenter, + followerAnchor: Alignment.bottomCenter, + offset: const Offset(-5, -5), + target: TIconButton( + iconData: Symbols.emoji_emotions_rounded, + iconColor: Colors.black54, + iconColorHovered: Colors.black87, + iconColorPressed: theme.primaryColor, + tooltip: appLocalizations.sticker, + ), + follower: StickerPicker( + onGiphyGifSelected: (GiphyGif value) { + final images = value.images; + final original = images?.original; + final previewWebp = images?.previewWebp; + if (original == null || previewWebp == null) { + return; + } + _sendImage( + appLocalizations, + original.webp ?? original.url, + previewWebp.url, + int.parse(original.width), + int.parse(original.height)); + }, + onEmojiSelected: _insertEmoji, + )), + TIconButton( + iconData: Symbols.folder_rounded, + iconColor: Colors.black54, + iconColorHovered: Colors.black87, + iconColorPressed: theme.primaryColor, + tooltip: appLocalizations.sticker, + onTap: () async { + // TODO + final file = await _pickFile(); + }, + ), + TIconButton( + iconData: Symbols.history_rounded, + iconColor: Colors.black54, + iconColorHovered: Colors.black87, + iconColorPressed: theme.primaryColor, + tooltip: appLocalizations.chatHistory, + onTap: () async { + await showSingleChatHistoryDialog( + context: context, conversation: _conversation!); + }, + ) + ], + ), + TIconButton( + iconData: Symbols.send_rounded, + iconColor: Colors.black54, + iconColorHovered: theme.primaryColor, + tooltip: appLocalizations.send, + disabled: _editorController.text.isEmpty, + onTap: _sendMessage) + ], + ), + ], + ); + + IgnorePointer _buildDropZoneMask( + ThemeData theme, AppLocalizations localizations) => + // Ignore pointer to not obstruct "DropRegion" + IgnorePointer( + child: AnimatedOpacity( + opacity: _dragging ? 1.0 : 0.0, + duration: const Duration(milliseconds: 100), + child: Padding( + padding: const EdgeInsets.only(right: 4, bottom: 4), + child: DottedBorder( + borderType: BorderType.RRect, + dashPattern: [12, 10], + color: theme.primaryColor, + strokeWidth: 2, + radius: const Radius.circular(8), + child: ClipRRect( + child: ColoredBox( + color: Colors.white.withValues(alpha: 0.6), + child: Center( + child: Text( + localizations.dropFilesHere, + style: TextStyle(color: theme.primaryColor), + ), + ), + ), + ), + ), + ))); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane_footer/message_editor.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane_footer/message_editor.dart new file mode 100644 index 0000000000..3216a2789a --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane_footer/message_editor.dart @@ -0,0 +1,223 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../../../../domain/conversation/models/conversation.dart'; +import '../../../../../../themes/index.dart'; +import '../../../../../components/index.dart'; + +const _mentionIndicator = '@'; + +class MessageEditor extends StatefulWidget { + const MessageEditor({ + super.key, + this.text, + required this.conversation, + this.contentPadding = EdgeInsets.zero, + this.autofocus = false, + this.readOnly = false, + required this.controller, + this.focusNode, + this.onTapOutside, + }); + + final String? text; + final Conversation conversation; + final EdgeInsets contentPadding; + final bool autofocus; + final bool readOnly; + final TextEditingController controller; + final FocusNode? focusNode; + final TapRegionCallback? onTapOutside; + + @override + State createState() => TMessageEditorState(); +} + +class TMessageEditorState extends State { + late GlobalKey _textFieldKey; + FocusNode? _focusNode; + late TMenuController _mentionUserMenuController; + TPopupFollowerController? _mentionUserPopupFollowerController; + + int? _lastCaretOffset; + + @override + void initState() { + super.initState(); + _textFieldKey = GlobalKey(); + if (widget.focusNode == null) { + _focusNode = FocusNode(); + } + _mentionUserMenuController = TMenuController(); + } + + @override + void dispose() { + _focusNode?.dispose(); + _tryHideMentionUserMenu(); + super.dispose(); + } + + void _tryHideMentionUserMenu() { + _mentionUserPopupFollowerController?.hide?.call(); + } + + (String, int) _getCharacterAtCursor() { + final controller = widget.controller; + final text = controller.text; + final length = text.length; + if (length == 0) { + return ('', 0); + } + final selection = controller.selection; + final offset = selection.baseOffset.clamp(0, length - 1); + return (text[offset], offset); + } + + @override + Widget build(BuildContext context) { + final readOnly = widget.readOnly; + final textStyleBodyMedium = context.theme.textTheme.bodyMedium!; + + final conversation = widget.conversation; + return Focus( + onKeyEvent: (context, event) { + final logicalKey = event.logicalKey; + final controller = _mentionUserPopupFollowerController; + if (controller == null || !controller.visible) { + return KeyEventResult.ignored; + } + if (logicalKey == LogicalKeyboardKey.escape || + widget.controller.selection.baseOffset <= _lastCaretOffset!) { + _tryHideMentionUserMenu(); + return KeyEventResult.handled; + } else if (logicalKey == LogicalKeyboardKey.arrowDown) { + if (event is KeyDownEvent || event is KeyRepeatEvent) { + _mentionUserMenuController.move?.call(true); + } + return KeyEventResult.handled; + } else if (logicalKey == LogicalKeyboardKey.arrowUp) { + if (event is KeyDownEvent || event is KeyRepeatEvent) { + _mentionUserMenuController.move?.call(false); + } + return KeyEventResult.handled; + } else if (logicalKey == LogicalKeyboardKey.enter) { + _mentionUserMenuController.selectCurrentEntry?.call(); + _tryHideMentionUserMenu(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: TextField( + key: _textFieldKey, + contextMenuBuilder: buildTextFieldContextMenu, + maxLength: 1000, + maxLines: null, + decoration: InputDecoration( + border: InputBorder.none, + isCollapsed: true, + contentPadding: widget.contentPadding, + // hide length counter + counterText: '', + ), + controller: widget.controller, + focusNode: widget.focusNode ?? _focusNode!, + readOnly: readOnly, + enableInteractiveSelection: true, + onTapOutside: widget.onTapOutside, + autofocus: widget.autofocus, + showCursor: !readOnly, + style: textStyleBodyMedium, + strutStyle: StrutStyle.fromTextStyle(textStyleBodyMedium, + forceStrutHeight: true), + selectionHeightStyle: BoxHeightStyle.max, + onChanged: (_) { + if (conversation is! GroupConversation) { + return; + } + final (char, offset) = _getCharacterAtCursor(); + if (char != _mentionIndicator) { + return; + } + _lastCaretOffset = offset; + // FIXME: https://github.com/flutter/flutter/issues/135354 + // Use a callback as a workaround to get the caret offset. + WidgetsBinding.instance.addPostFrameCallback((_) { + final targetGlobalRect = getCaretRect(_textFieldKey); + if (targetGlobalRect == null) { + return; + } + _tryHideMentionUserMenu(); + _mentionUserPopupFollowerController = TPopupFollowerController(); + showPopup( + context: context, + controller: _mentionUserPopupFollowerController, + targetGlobalRect: targetGlobalRect, + targetAnchor: Alignment.topRight, + followerAnchor: Alignment.bottomLeft, + animate: false, + follower: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 96, maxWidth: 128), + child: TMenu( + controller: _mentionUserMenuController, + dense: true, + entries: conversation.contact.members + .map( + (e) => TMenuEntry(value: e.userId, label: e.name), + ) + .toList(), + onSelected: (entry) { + // TODO + debugPrint('selected: ${entry.label}'); + _tryHideMentionUserMenu(); + }), + ), + ); + }); + }, + ), + ); + } +} + +/// Copied from https://github.com/mathiasbynens/emoji-regex/blob/v10.4.0/index.js +final _emojiRegex = RegExp( + r'[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uD83C[\uDFFB-\uDFFF]|\uFE0F)?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26D3\uFE0F?(?:\u200D\uD83D\uDCA5)?|\u26F9(?:\uD83C[\uDFFB-\uDFFF]|\uFE0F)?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uD83C[\uDFFB-\uDFFF]|\uFE0F)?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF43\uDF45-\uDF4A\uDF4C-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDF44(?:\u200D\uD83D\uDFEB)?|\uDF4B(?:\u200D\uD83D\uDFE9)?|\uDFC3(?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D(?:[\u2640\u2642]\uFE0F?(?:\u200D\u27A1\uFE0F?)?|\u27A1\uFE0F?))?|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4\uDEB5](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uD83C[\uDFFB-\uDFFF]|\uFE0F)?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC25\uDC27-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE41\uDE43\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDC-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC08(?:\u200D\u2B1B)?|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC26(?:\u200D(?:\u2B1B|\uD83D\uDD25))?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uD83C[\uDFFB-\uDFFF]|\uFE0F)?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?|\uDE42(?:\u200D[\u2194\u2195]\uFE0F?)?|\uDEB6(?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D(?:[\u2640\u2642]\uFE0F?(?:\u200D\u27A1\uFE0F?)?|\u27A1\uFE0F?))?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF8](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE7C\uDE80-\uDE89\uDE8F-\uDEC2\uDEC6\uDECE-\uDEDC\uDEDF-\uDEE9]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDCE(?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D(?:[\u2640\u2642]\uFE0F?(?:\u200D\u27A1\uFE0F?)?|\u27A1\uFE0F?))?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1|\uDDD1\u200D\uD83E\uDDD2(?:\u200D\uD83E\uDDD2)?|\uDDD2(?:\u200D\uD83E\uDDD2)?))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?)'); + +class EmojiTextEditingController extends TextEditingController { + EmojiTextEditingController({ + String? text, + }) : super(text: text); + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + bool? withComposing, + }) { + final appThemeExtension = context.appThemeExtension; + return generateTextSpan(appThemeExtension, style, text); + } +} + +TextSpan generateTextSpan( + AppThemeExtension appThemeExtension, TextStyle? style, String text) { + final messageTextStyle = style == null + ? appThemeExtension.chatSessionMessageTextStyle + : style.merge(appThemeExtension.chatSessionMessageTextStyle); + final messageEmojiTextStyle = style == null + ? appThemeExtension.chatSessionMessageEmojiTextStyle + : style.merge(appThemeExtension.chatSessionMessageEmojiTextStyle); + final spans = []; + // find out all emoji text and non-emoji text into different spans + text.splitMapJoin(_emojiRegex, onMatch: (text) { + spans.add(TextSpan(text: text.group(0)!, style: messageEmojiTextStyle)); + return ''; + }, onNonMatch: (text) { + spans.add(TextSpan(text: text, style: messageTextStyle)); + return ''; + }); + return TextSpan(children: spans); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane_header.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane_header.dart new file mode 100644 index 0000000000..77f60581ec --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/chat_session_pane_header.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../components/index.dart'; +import '../../../../components/t_menu/t_context_menu.dart'; +import '../view_models/selected_conversation_view_model.dart'; +import 'chat_session_pane.dart'; + +class ChatSessionPaneHeader extends ConsumerStatefulWidget { + const ChatSessionPaneHeader( + {super.key, required this.drawerController, required this.supportDrawer}); + + final TDrawerController drawerController; + final bool supportDrawer; + + @override + ConsumerState createState() => + _ChatSessionPaneHeaderState(); +} + +class _ChatSessionPaneHeaderState extends ConsumerState { + @override + Widget build(BuildContext context) => Stack( + children: [ + const TWindowControlZone( + toggleMaximizeOnDoubleTap: true, + ), + Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Use Flexible to prevent overflow + Flexible( + child: Padding( + padding: const EdgeInsets.only( + left: 28, + right: 128, + ), + child: SelectionArea( + contextMenuBuilder: buildContextMenuForSelectableRegion, + child: Text( + ref + .watch(selectedConversationViewModel) + ?.contact + .name ?? + '', + maxLines: 1, + style: const TextStyle( + fontSize: 20, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + if (widget.supportDrawer) + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TapRegion( + groupId: chatSessionDetailsDrawerGroupId, + child: TIconButton( + onTap: () => widget.drawerController.toggle!.call(), + tooltip: + ref.watch(appLocalizationsViewModel).chatInfo, + iconData: Symbols.more_horiz_rounded, + ), + ) + ], + ) + ], + ), + ) + ], + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message.dart new file mode 100644 index 0000000000..7eff1632df --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message.dart @@ -0,0 +1,151 @@ +import 'dart:typed_data'; + +import 'package:fixnum/fixnum.dart'; + +import '../../../../../../domain/message/models/message_delivery_status.dart'; +import '../../../../../../domain/message/models/message_type.dart'; +import '../../../../../../domain/message/services/message_service.dart'; +import '../../../../../../infra/sqlite/user_message_database.dart'; + +class ChatMessage { + factory ChatMessage.fromMessageTableData( + MessageTableData data, Int64 loggedInUserId) { + final senderId = data.senderId.toInt64(); + final isGroupMessage = data.isGroupMessage; + final contactId = data.contactId; + return ChatMessage.parse( + text: data.txt, + records: data.records, + messageId: data.id.toInt64(), + groupId: isGroupMessage ? contactId : null, + recipientId: isGroupMessage ? null : contactId, + senderId: senderId, + sentByMe: senderId == loggedInUserId, + isGroupMessage: isGroupMessage, + timestamp: data.createdDate, + // We only store sent messages in the database, + // so we hardcode it to keep the logic simple. + status: MessageDeliveryStatus.delivered); + } + + factory ChatMessage.parse({ + String? text, + List? records, + required Int64 messageId, + Int64? groupId, + Int64? recipientId, + required Int64 senderId, + required bool sentByMe, + required bool isGroupMessage, + required DateTime timestamp, + required MessageDeliveryStatus status, + }) { + final info = MessageService.parseMessageInfo(text); + return ChatMessage( + type: info.type, + messageId: messageId, + text: text, + records: records, + groupId: groupId, + recipientId: recipientId, + senderId: senderId, + sentByMe: sentByMe, + isGroupMessage: isGroupMessage, + timestamp: timestamp, + status: status, + originalUrl: info.originalUrl, + originalWidth: info.originalWidth, + originalHeight: info.originalHeight, + mentionAll: info.mentionAll ?? false, + mentionedUserIds: info.mentionedUserIds ?? const {}, + ); + } + + const ChatMessage( + {required this.type, + required this.messageId, + this.groupId, + this.recipientId, + required this.senderId, + required this.sentByMe, + required this.isGroupMessage, + this.text, + this.records, + required this.timestamp, + required this.status, + required this.mentionAll, + required this.mentionedUserIds, + this.originalUrl, + this.originalWidth, + this.originalHeight}); + + final MessageType type; + final Int64 messageId; + final Int64? groupId; + final Int64? recipientId; + final Int64 senderId; + final bool sentByMe; + final bool isGroupMessage; + final String? text; + final List? records; + + // final List spans; + final DateTime timestamp; + final MessageDeliveryStatus status; + + final bool mentionAll; + final Set mentionedUserIds; + + final String? originalUrl; + final double? originalWidth; + final double? originalHeight; + + ChatMessage copyWith({ + MessageType? type, + Int64? messageId, + Int64? groupId, + Int64? recipientId, + Int64? senderId, + bool? sentByMe, + bool? isGroupMessage, + String? text, + List? records, + DateTime? timestamp, + MessageDeliveryStatus? status, + bool? mentionAll, + Set? mentionedUserIds, + String? originalUrl, + double? originalWidth, + double? originalHeight, + }) => + ChatMessage( + type: type ?? this.type, + messageId: messageId ?? this.messageId, + groupId: groupId ?? this.groupId, + recipientId: recipientId ?? this.recipientId, + senderId: senderId ?? this.senderId, + sentByMe: sentByMe ?? this.sentByMe, + isGroupMessage: isGroupMessage ?? this.isGroupMessage, + text: text ?? this.text, + records: records ?? this.records, + timestamp: timestamp ?? this.timestamp, + status: status ?? this.status, + mentionAll: mentionAll ?? this.mentionAll, + mentionedUserIds: mentionedUserIds ?? this.mentionedUserIds, + originalUrl: originalUrl ?? this.originalUrl, + originalWidth: originalWidth ?? this.originalWidth, + originalHeight: originalHeight ?? this.originalHeight, + ); +} + +enum ChatMessageSpanType { plain, emoji } + +class ChatMessageSpan { + const ChatMessageSpan({ + required this.type, + required this.text, + }); + + final ChatMessageSpanType type; + final String text; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble.dart new file mode 100644 index 0000000000..8dc3f4d1b4 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble.dart @@ -0,0 +1,261 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../../../../domain/message/models/message_delivery_status.dart'; +import '../../../../../../../domain/message/models/message_type.dart'; +import '../../../../../../../domain/user/models/user.dart'; +import '../../../../../../l10n/view_models/date_format_view_models.dart'; +import '../../../../../../themes/index.dart'; +import '../../../../../components/index.dart'; +import '../../../shared_components/user_profile_popup.dart'; +import '../message.dart'; +import 'message_bubble_audio.dart'; +import 'message_bubble_image.dart'; +import 'message_bubble_text.dart'; +import 'message_bubble_video.dart'; + +class MessageBubble extends ConsumerStatefulWidget { + const MessageBubble({ + Key? key, + required this.currentUser, + required this.sender, + required this.messages, + required this.availableWidth, + this.onRetry, + }) : super(key: key); + + final User currentUser; + final User sender; + final List messages; + final double availableWidth; + final Future Function()? onRetry; + + @override + ConsumerState createState() => _MessageBubbleState(); +} + +class _MessageBubbleState extends ConsumerState { + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + return Padding( + padding: Sizes.paddingV4H8, + child: widget.messages.first.sentByMe + ? _buildSentMessageBubble(context, now) + : _buildReceivedMessageBubble(context, now)); + } + + Row _buildSentMessageBubble(BuildContext context, DateTime now) { + final messages = widget.messages; + final messageCount = messages.length; + assert(messageCount > 0, 'There should be at least one message'); + return Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + if (messageCount == 1) + _buildMessage(context, messages.first, now, MainAxisAlignment.start, + CrossAxisAlignment.end, Sizes.borderRadiusCircular4) + else + Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 2, + children: [ + for (var i = 0; i < messageCount; i++) + _buildMessage( + context, + messages[i], + now, + MainAxisAlignment.start, + i == 0 ? CrossAxisAlignment.end : null, + i == 0 + ? const BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(4), + bottomLeft: Radius.circular(4)) + : i == messageCount - 1 + ? const BorderRadius.only( + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(4), + topLeft: Radius.circular(4), + ) + : const BorderRadius.only( + topLeft: Radius.circular(4), + bottomLeft: Radius.circular(4), + )) + ], + ), + UserProfilePopup( + user: widget.sender, + popupAnchor: Alignment.topRight, + ) + ], + ); + } + + Row _buildReceivedMessageBubble(BuildContext context, DateTime now) { + final messages = widget.messages; + final messageCount = messages.length; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + UserProfilePopup(user: widget.sender), + if (messageCount == 1) + _buildMessage(context, messages.first, now, MainAxisAlignment.end, + CrossAxisAlignment.start, Sizes.borderRadiusCircular4) + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + for (var i = 0; i < messageCount; i++) + _buildMessage( + context, + messages[i], + now, + MainAxisAlignment.end, + i == 0 ? CrossAxisAlignment.start : null, + i == 0 + ? const BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(4), + bottomRight: Radius.circular(4)) + : i == messageCount - 1 + ? const BorderRadius.only( + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(4), + topRight: Radius.circular(4), + ) + : const BorderRadius.only( + topRight: Radius.circular(4), + bottomRight: Radius.circular(4), + )) + ]) + ], + ); + } + + Widget _buildMessage( + BuildContext context, + ChatMessage message, + DateTime now, + MainAxisAlignment mainAxisAlignment, + CrossAxisAlignment? infoAlignment, + BorderRadius borderRadius) { + const spacing = 12.0; + final deliveryStatus = message.status; + final appThemeExtension = context.appThemeExtension; + + final content = Row( + mainAxisAlignment: mainAxisAlignment, + spacing: spacing, + children: [ + if (deliveryStatus == MessageDeliveryStatus.failed) + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () async { + await widget.onRetry!(); + }, + child: DecoratedBox( + decoration: BoxDecoration( + color: + appThemeExtension.messageBubbleErrorIconBackgroundColor, + shape: BoxShape.circle, + ), + child: Padding( + padding: const EdgeInsets.all(1), + child: Icon( + Symbols.exclamation_rounded, + color: appThemeExtension.messageBubbleErrorIconColor, + size: 20, + ), + ), + ), + ), + ) + else if (deliveryStatus == MessageDeliveryStatus.delivering) + const RepaintBoundary(child: CupertinoActivityIndicator()), + IntrinsicWidth( + // TODO: we may support compound messages in the future. + child: switch (message.type) { + MessageType.text => TSelectionContainer( + visible: true, + child: MessageBubbleText( + currentUser: widget.currentUser, + message: message, + availableWidth: widget.availableWidth - + spacing - + TAvatarSize.medium.containerSize, + borderRadius: borderRadius, + ), + ), + MessageType.video => MessageBubbleVideo( + url: Uri.parse(message.originalUrl!), + width: message.originalWidth!, + height: message.originalHeight!, + ), + MessageType.audio => + MessageBubbleAudio(url: Uri.parse(message.originalUrl!)), + MessageType.image => MessageBubbleImage( + url: message.originalUrl!, + width: message.originalWidth!, + height: message.originalHeight!, + ), + MessageType.file => Text(message.originalUrl ?? ''), + MessageType.youtube => Text(message.originalUrl!), + }, + ), + ], + ); + if (infoAlignment == null) { + return content; + } + final sender = widget.sender; + return Column( + crossAxisAlignment: infoAlignment, + spacing: 4, + children: [ + Row( + spacing: 8, + children: [ + if (sender.userId != widget.currentUser.userId) + if (message.isGroupMessage) + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + // TODO + // widget.mentionUser(message.senderId); + }, + child: Text(sender.name, + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w600)), + ), + ) + else + Text(sender.name, + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w600)), + Text(_formatMessageTimestamp(now, widget.messages.first.timestamp), + style: const TextStyle(fontSize: 12)), + ], + ), + content, + ], + ); + } + + String _formatMessageTimestamp(DateTime now, DateTime timestamp) { + if (now.year != timestamp.year) { + return ref.watch(dateFormatViewModel_yMdjms).format(timestamp); + } else if (now.month != timestamp.month || now.day != timestamp.day) { + return ref.watch(dateFormatViewModel_Mdjms).format(timestamp); + } else { + return ref.watch(dateFormatViewModel_jm).format(timestamp); + } + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_audio.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_audio.dart new file mode 100644 index 0000000000..5a6131ede5 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_audio.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:path/path.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../../../../../infra/crypto/crypto_utils.dart'; +import '../../../../../../../infra/exception/exception_extensions.dart'; +import '../../../../../../../infra/exception/user_visible_exception.dart'; +import '../../../../../../../infra/http/downloaded_file.dart'; +import '../../../../../../../infra/http/file_too_large_exception.dart'; +import '../../../../../../../infra/http/http_utils.dart'; +import '../../../../../../../infra/io/path_utils.dart'; +import '../../../../../../../infra/units/file_size_extensions.dart'; +import '../../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../../../themes/index.dart'; + +import '../../../../../components/index.dart'; + +const _width = 286.0; +const _height = 52.0; +const _maxAllowedMb = 100; +final _maxAllowedBytes = _maxAllowedMb.MB; + +class MessageBubbleAudio extends ConsumerStatefulWidget { + const MessageBubbleAudio({Key? key, required this.url}) : super(key: key); + + final Uri url; + + @override + ConsumerState createState() => _MessageBubbleAudioState(); +} + +class _MessageBubbleAudioState extends ConsumerState { + VideoPlayerController? _controller; + late Future _initializeAudioControllerFuture; + + @override + void didUpdateWidget(MessageBubbleAudio oldWidget) { + super.didUpdateWidget(oldWidget); + } + + @override + void initState() { + super.initState(); + + _initializeAudioControllerFuture = Future.microtask(() async { + final url = widget.url; + final urlStr = url.toString(); + final ext = extension(urlStr); + final fileName = '${CryptoUtils.getSha256ByString(urlStr)}.$ext'; + final filePath = PathUtils.joinPathInUserScope(['files', fileName]); + final file = File(filePath); + final VideoPlayerController controller; + if (await file.exists()) { + controller = VideoPlayerController.file(File(filePath)); + } else { + final DownloadedFile? downloadedFile; + try { + downloadedFile = await HttpUtils.downloadFile( + taskId: filePath, + uri: url, + filePath: filePath, + maxBytes: _maxAllowedBytes); + } on FileTooLargeException catch (e) { + throw UserVisibleException( + e, + (cause) => ref + .read(appLocalizationsViewModel) + .failedToDownloadFileTooLarge(_maxAllowedMb)); + } catch (e) { + throw UserVisibleException( + e, (_) => ref.read(appLocalizationsViewModel).failedToDownload); + } + if (downloadedFile == null) { + throw UserVisibleException( + null, (_) => ref.read(appLocalizationsViewModel).videoNotFound); + } + controller = VideoPlayerController.file(downloadedFile.file); + } + await controller.setVolume(1.0); + controller.addListener(() { + if (controller.value.position == controller.value.duration) { + controller.seekTo(Duration.zero); + setState(() {}); + } + }); + await controller.initialize(); + _controller = controller; + }); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => SizedBox( + width: _width, + height: _height, + child: TAsyncBuilder( + future: _initializeAudioControllerFuture, + builder: (context, snapshot) => snapshot.when( + data: (data) => _buildStack(), + error: (error, stackTrace) { + final message = switch (error) { + UserVisibleException(:final message) => message, + final Exception e => + '${ref.read(appLocalizationsViewModel).error}: ${e.message}', + _ => '${ref.read(appLocalizationsViewModel).error}: $error', + }; + return DecoratedBox( + decoration: BoxDecoration( + color: context.appThemeExtension.maskColor, + ), + child: Center( + child: Text( + message, + style: + const TextStyle(color: Colors.white, fontSize: 16), + ), + ), + ); + }, + loading: () => const Center( + child: RepaintBoundary(child: CircularProgressIndicator())), + ))); + + // TODO + Widget _buildStack() { + final controller = _controller!; + return Stack( + children: [ + AspectRatio( + aspectRatio: controller.value.aspectRatio, + child: VideoPlayer(controller), + ), + if (!controller.value.isPlaying) + Center( + child: SizedBox( + width: 36, + height: 36, + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color.fromARGB(128, 0, 0, 0), + shape: BoxShape.circle, + border: Border.all(color: Colors.white), + ), + child: const Center( + child: Icon( + Symbols.play_arrow_rounded, + color: Colors.white, + size: 20, + ), + ), + ), + ), + ), + Positioned.fill( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () async { + if (controller.value.isPlaying) { + await controller.pause(); + } else { + await controller.play(); + } + setState(() {}); + }, + ), + ), + ) + ], + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_image.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_image.dart new file mode 100644 index 0000000000..0cecb483ce --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_image.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../../../../../../../infra/env/env_vars.dart'; +import '../../../../../../../infra/ui/size_utils.dart'; +import '../../../../../../themes/index.dart'; +import '../../../../../components/index.dart'; +import 'message_image_provider.dart'; + +const _imageBorderWidth = 1.0; + +class MessageBubbleImage extends StatefulWidget { + const MessageBubbleImage( + {Key? key, required this.url, required this.width, required this.height}) + : super(key: key); + + final String url; + final double width; + final double height; + + @override + State createState() => _MessageBubbleImageState(); +} + +class _MessageBubbleImageState extends State { + late MessageImageProvider _originalImageProvider; + late MessageImageProvider _thumbnailProvider; + late double _width; + late double _height; + + @override + void initState() { + super.initState(); + _originalImageProvider = MessageImageProvider(widget.url, false); + _thumbnailProvider = MessageImageProvider(widget.url, true); + + final size = SizeUtils.keepAspectRatio( + Size(widget.width, widget.height), + EnvVars.messageImageThumbnailSizeWidth, + EnvVars.messageImageThumbnailSizeHeight); + _width = size.width; + _height = size.height; + } + + @override + void dispose() { + // Dispose the original image provider to save memory promptly. + _originalImageProvider.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () async { + // TODO: show a tip to let user know if the original image has been deleted. + unawaited(showImageViewerDialog(context, _originalImageProvider)); + }, + child: _buildThumbnail())); + + Image _buildThumbnail() => Image( + isAntiAlias: true, + gaplessPlayback: true, + image: _thumbnailProvider, + fit: BoxFit.contain, + loadingBuilder: (context, child, loadingProgress) { + // FIXME: https://github.com/flutter/flutter/issues/85966 + if (loadingProgress == null && + ((child as Semantics).child as RawImage).image != null) { + return ClipRRect( + borderRadius: Sizes.borderRadiusCircular4, + child: Padding( + padding: const EdgeInsets.all(_imageBorderWidth), + child: child, + ), + ); + } + return SizedBox( + width: _width, + height: _height, + child: const ClipRRect( + borderRadius: Sizes.borderRadiusCircular4, + child: DecoratedBox( + decoration: BoxDecoration(color: Colors.black12), + child: + RepaintBoundary(child: CupertinoActivityIndicator())), + )); + }, + errorBuilder: (context, error, stackTrace) => _buildError(), + ); + +// todo: click to download + // handle different cases + Widget _buildError() => const SizedBox( + width: 100, + height: 100, + child: TImageBroken(), + ); + +// Stack _buildError() => Stack( +// children: [ +// SizedBox( +// width: EnvVars.messageImageThumbnailSizeWidth.toDouble(), +// height: EnvVars.messageImageThumbnailSizeWidth.toDouble(), +// child: const DecoratedBox( +// decoration: BoxDecoration(color: Colors.black12), +// ), +// ), +// const Center( +// child: Icon(Symbols.image_not_supported_rounded), +// ) +// ], +// ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_text.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_text.dart new file mode 100644 index 0000000000..57bb38c2cb --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_text.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +import '../../../../../../../domain/user/models/user.dart'; +import '../../../../../../themes/index.dart'; + +import '../../../../../components/index.dart'; +import '../chat_session_pane_footer/message_editor.dart'; +import '../message.dart'; + +class MessageBubbleText extends StatefulWidget { + const MessageBubbleText({ + Key? key, + required this.currentUser, + required this.message, + required this.availableWidth, + required this.borderRadius, + }) : super(key: key); + + final User currentUser; + final ChatMessage message; + final double availableWidth; + final BorderRadius borderRadius; + + @override + State createState() => _MessageBubbleTextState(); +} + +const _mentionIconSize = 22.0; + +class _MessageBubbleTextState extends State { + late bool _isMentioned; + TextSpan? _textSpan; + + @override + void initState() { + super.initState(); + final message = widget.message; + _isMentioned = !message.sentByMe && + (message.mentionAll || + message.mentionedUserIds.contains(widget.currentUser.userId)); + } + + @override + Widget build(BuildContext context) { + final appThemeExtension = context.appThemeExtension; + _textSpan ??= + generateTextSpan(appThemeExtension, null, widget.message.text!); + const color = Color.fromARGB(255, 204, 74, 49); + Widget content = IntrinsicWidth( + child: DecoratedBox( + decoration: BoxDecoration( + color: widget.message.sentByMe + ? const Color.fromARGB(255, 149, 216, 248) + : Colors.white, + borderRadius: widget.borderRadius, + border: _isMentioned + ? const Border( + left: BorderSide( + color: color, + width: 4, + ), + ) + : null, + ), + child: ConstrainedBox( + constraints: + BoxConstraints(maxWidth: widget.availableWidth * 0.6), + child: Padding( + padding: Sizes.paddingV8H8, + child: TText.rich( + _textSpan!, + strutStyle: StrutStyle.fromTextStyle( + appThemeExtension.chatSessionMessageTextStyle, + forceStrutHeight: true), + // TODO: Wait for: + // 1. https://github.com/flutter/flutter/pull/140982 + // 2. https://github.com/flutter/flutter/issues/104547 + // selectionHeightStyle: BoxHeightStyle.max, + ), + ), + ))); + + if (_isMentioned) { + content = Stack(clipBehavior: Clip.none, children: [ + content, + const Positioned( + child: SizedBox( + width: _mentionIconSize, + height: _mentionIconSize, + child: DecoratedBox( + decoration: + BoxDecoration(color: color, shape: BoxShape.circle), + child: Icon( + Symbols.alternate_email_rounded, + color: Colors.white, + size: _mentionIconSize - 6, + ))), + top: 0, + bottom: 0, + right: -_mentionIconSize / 2, + ) + ]); + } + return content; + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_video.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_video.dart new file mode 100644 index 0000000000..bc578e3757 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_video.dart @@ -0,0 +1,202 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:path/path.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../../../../../infra/crypto/crypto_utils.dart'; +import '../../../../../../../infra/exception/exception_extensions.dart'; +import '../../../../../../../infra/exception/user_visible_exception.dart'; +import '../../../../../../../infra/http/downloaded_file.dart'; +import '../../../../../../../infra/http/file_too_large_exception.dart'; +import '../../../../../../../infra/http/http_utils.dart'; +import '../../../../../../../infra/io/path_utils.dart'; +import '../../../../../../../infra/ui/size_utils.dart'; +import '../../../../../../../infra/units/file_size_extensions.dart'; +import '../../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../../../themes/index.dart'; + +import '../../../../../components/index.dart'; + +const _maxAllowedMb = 100; +final _maxAllowedBytes = _maxAllowedMb.MB; + +class MessageBubbleVideo extends ConsumerStatefulWidget { + const MessageBubbleVideo( + {Key? key, required this.url, required this.width, required this.height}) + : super(key: key); + + final Uri url; + final double width; + final double height; + + @override + ConsumerState createState() => _MessageBubbleVideoState(); +} + +class _MessageBubbleVideoState extends ConsumerState { + VideoPlayerController? _controller; + late Future _initializeVideoControllerFuture; + late double _width; + late double _height; + + @override + void didUpdateWidget(MessageBubbleVideo oldWidget) { + super.didUpdateWidget(oldWidget); + } + + @override + void initState() { + super.initState(); + + final size = + SizeUtils.keepAspectRatio(Size(widget.width, widget.height), 200, 200); + _width = size.width; + _height = size.height; + + _initializeVideoControllerFuture = Future.microtask(() async { + final url = widget.url; + final urlStr = url.toString(); + final ext = extension(urlStr); + final fileName = '${CryptoUtils.getSha256ByString(urlStr)}.$ext'; + final filePath = PathUtils.joinPathInUserScope(['files', fileName]); + final file = File(filePath); + final VideoPlayerController controller; + if (await file.exists()) { + controller = VideoPlayerController.file(File(filePath)); + } else { + final DownloadedFile? downloadedFile; + try { + downloadedFile = await HttpUtils.downloadFile( + taskId: filePath, + uri: url, + filePath: filePath, + maxBytes: _maxAllowedBytes); + } on FileTooLargeException catch (e) { + throw UserVisibleException( + e, + (cause) => ref + .read(appLocalizationsViewModel) + .failedToDownloadFileTooLarge(_maxAllowedMb)); + } catch (e) { + throw UserVisibleException( + e, (_) => ref.read(appLocalizationsViewModel).failedToDownload); + } + if (downloadedFile == null) { + throw UserVisibleException( + null, (_) => ref.read(appLocalizationsViewModel).videoNotFound); + } + controller = VideoPlayerController.file(downloadedFile.file); + } + await controller.setVolume(1.0); + controller.addListener(() { + if (controller.value.position == controller.value.duration) { + controller.seekTo(Duration.zero); + setState(() {}); + } + }); + await controller.initialize(); + _controller = controller; + }); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => SizedBox( + width: _width, + height: _height, + child: TAsyncBuilder( + future: _initializeVideoControllerFuture, + builder: (context, snapshot) => snapshot.when( + data: (data) => _buildStack(), + error: (error, stackTrace) { + final message = switch (error) { + UserVisibleException(:final message) => message, + final Exception e => + '${ref.read(appLocalizationsViewModel).error}: ${e.message}', + _ => '${ref.read(appLocalizationsViewModel).error}: $error', + }; + return DecoratedBox( + decoration: BoxDecoration( + color: context.appThemeExtension.maskColor, + ), + child: Center( + child: Row( + spacing: 16, + children: [ + const Icon( + Symbols.info_i_rounded, + color: Colors.white, + size: 20, + ), + Text( + message, + style: const TextStyle( + color: Colors.white, fontSize: 16), + ), + ], + ), + ), + ); + }, + loading: () => const Center( + child: RepaintBoundary(child: CircularProgressIndicator())), + ))); + + Widget _buildStack() { + final controller = _controller; + return Stack( + children: [ + AspectRatio( + aspectRatio: controller!.value.aspectRatio, + child: VideoPlayer(controller), + ), + if (!controller.value.isPlaying) + Center( + child: SizedBox( + width: 36, + height: 36, + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color.fromARGB(128, 0, 0, 0), + shape: BoxShape.circle, + border: Border.all(color: Colors.white), + ), + child: const Center( + child: Icon( + Symbols.play_arrow_rounded, + color: Colors.white, + size: 20, + ), + ), + ), + ), + ), + Positioned.fill( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () async { + if (controller.value.isPlaying) { + await controller.pause(); + } else { + await controller.play(); + } + setState(() {}); + }, + ), + ), + ) + ], + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_youtube.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_youtube.dart new file mode 100644 index 0000000000..62be2d17e9 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_bubble_youtube.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +class MessageBubbleYoutube extends StatelessWidget { + const MessageBubbleYoutube({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + // TODO: use webview + return const Placeholder(); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_image_provider.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_image_provider.dart new file mode 100644 index 0000000000..866ef4aeee --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/message_image_provider.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:path/path.dart'; + +import '../../../../../../../infra/crypto/crypto_utils.dart'; +import '../../../../../../../infra/env/env_vars.dart'; +import '../../../../../../../infra/http/file_too_large_exception.dart'; +import '../../../../../../../infra/http/http_utils.dart'; +import '../../../../../../../infra/http/resource_not_found_exception.dart'; +import '../../../../../../../infra/io/path_utils.dart'; +import '../../../../../../../infra/media/corrupted_media_file_exception.dart'; +import '../../../../../../../infra/rust/api/image.dart'; +import '../../../../../../../infra/task/task_utils.dart'; +import '../../../../../../../infra/units/file_size_extensions.dart'; +import '../message_media_file.dart'; + +class MessageImageProvider extends ImageProvider { + MessageImageProvider(this.originalImageUrl, this.asThumbnail); + + final String originalImageUrl; + final bool asThumbnail; + final StreamController chunkEvents = + StreamController(); + MessageMediaFile? mediaFile; + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + MessageImageProvider key, ImageDecoderCallback decode) => + MultiFrameImageStreamCompleter( + codec: _load(decode), + scale: 1.0, + debugLabel: '$originalImageUrl:$asThumbnail', + chunkEvents: chunkEvents.stream, + informationCollector: () sync* { + yield ErrorDescription( + 'Cache Entry ID: $originalImageUrl:$asThumbnail'); + }, + ); + + Future _load(ImageDecoderCallback decode) async { + final bytes = await _fetchImage(); + final buffer = await ImmutableBuffer.fromUint8List(bytes); + return decode(buffer); + } + + Future _fetchImage() async { + final url = originalImageUrl; + final urlStr = url.toString(); + final ext = extension(urlStr); + final fileBaseName = CryptoUtils.getSha256ByString(urlStr); + final fileFullName = '$fileBaseName$ext'; + final outputOriginalImagePath = + PathUtils.joinPathInUserScope(['files', fileFullName]); + final outputThumbnailPath = + PathUtils.joinPathInUserScope(['files', '$fileBaseName-thumbnail$ext']); + if (asThumbnail) { + final outputThumbnailFile = File(outputThumbnailPath); + if (await outputThumbnailFile.exists()) { + final bytes = await outputThumbnailFile.readAsBytes(); + this.mediaFile = MessageMediaFile( + originalMediaUrl: url, + thumbnailPath: outputThumbnailPath, + thumbnailBytes: bytes); + return bytes; + } + chunkEvents.add(const ImageChunkEvent( + cumulativeBytesLoaded: 0, expectedTotalBytes: null)); + final mediaFile = await TaskUtils.cacheFutureProvider( + id: 'download:$url', + futureProvider: () => + _fetchImage0(url, outputOriginalImagePath, outputThumbnailPath)); + this.mediaFile = mediaFile; + return mediaFile.thumbnailBytes ?? mediaFile.originalMediaBytes!; + } else { + final outputOriginalImageFile = File(outputOriginalImagePath); + if (await outputOriginalImageFile.exists()) { + final bytes = await outputOriginalImageFile.readAsBytes(); + this.mediaFile = MessageMediaFile( + originalMediaUrl: url, + originalMediaPath: outputOriginalImagePath, + originalMediaBytes: bytes); + return bytes; + } + chunkEvents.add(const ImageChunkEvent( + cumulativeBytesLoaded: 0, expectedTotalBytes: null)); + final mediaFile = await TaskUtils.cacheFutureProvider( + id: 'download:$url', + futureProvider: () => + _fetchImage0(url, outputOriginalImagePath, outputThumbnailPath)); + this.mediaFile = mediaFile; + return mediaFile.originalMediaBytes!; + } + } + + Future _fetchImage0(String uri, + String outputOriginalImagePath, String outputThumbnailPath) async { + final originalImageFile = await HttpUtils.downloadFile( + uri: Uri.parse(uri), + filePath: outputOriginalImagePath, + maxBytes: EnvVars.messageImageMaxDownloadableSizeBytes.MB, + ); + if (originalImageFile == null) { + throw ResourceNotFoundException(uri); + } + final originalImageBytes = await originalImageFile.bytes; + if (originalImageBytes.isEmpty) { + throw ResourceNotFoundException(uri); + } + final resizeResult = await resize( + inputPath: originalImageFile.file.path, + outputPath: outputThumbnailPath, + width: EnvVars.messageImageThumbnailSizeWidth.toInt(), + height: EnvVars.messageImageThumbnailSizeHeight.toInt()); + final errorType = resizeResult.errorType; + if (errorType == null) { + // TODO: optimize memory usage. + if (resizeResult.resized) { + final thumbnailBytes = await File(outputThumbnailPath).readAsBytes(); + return MessageMediaFile( + originalMediaUrl: uri, + originalMediaPath: outputOriginalImagePath, + originalMediaBytes: originalImageBytes, + thumbnailPath: outputThumbnailPath, + thumbnailBytes: thumbnailBytes, + ); + } else { + return MessageMediaFile( + originalMediaUrl: uri, + originalMediaPath: outputOriginalImagePath, + originalMediaBytes: originalImageBytes, + ); + } + } else { + return switch (errorType) { + ResizeError.decoding => throw const CorruptedMediaFileException(), + ResizeError.parameter => throw ArgumentError(), + ResizeError.limits => throw const FileTooLargeException(), + ResizeError.unsupported || ResizeError.ioError => throw Exception('io') + }; + } + } + + void dispose() { + PaintingBinding.instance.imageCache.evict(this, includeLive: false); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is MessageImageProvider && + other.originalImageUrl == originalImageUrl && + other.asThumbnail == asThumbnail; + } + + @override + int get hashCode => originalImageUrl.hashCode ^ asThumbnail.hashCode; + + @override + String toString() => + '${objectRuntimeType(this, 'MessageImageProvider')}("$originalImageUrl:$asThumbnail")'; + + bool loaded() => mediaFile != null; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/quoted_message.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/quoted_message.dart new file mode 100644 index 0000000000..40910e4346 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_bubble/quoted_message.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import '../../../../../../../domain/user/models/index.dart'; +import '../../../../../../themes/index.dart'; +import 'message_bubble.dart'; + +// TODO: https://allthings.how/how-to-quote-or-reply-to-a-message-in-teams-chat/ +class QuotedMessage extends StatelessWidget { + const QuotedMessage( + {super.key, required this.user, required this.quotedMessageBubble}); + + final User user; + final MessageBubble quotedMessageBubble; + + @override + Widget build(BuildContext context) { + const left = 4.0; + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.grey.shade200, + border: Border( + left: BorderSide(color: Colors.grey[350]!, width: left))), + child: Padding( + padding: Sizes.paddingV4H8.add(const EdgeInsets.only(left: left)), + child: Column( + children: [ + Text(user.name), + quotedMessageBubble, + ], + ), + )); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_media_file.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_media_file.dart new file mode 100644 index 0000000000..5878b34499 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/message_media_file.dart @@ -0,0 +1,23 @@ +import 'dart:typed_data'; + +class MessageMediaFile { + const MessageMediaFile( + {required this.originalMediaUrl, + this.originalMediaPath, + this.originalMediaBytes, + this.thumbnailImageUrl, + this.thumbnailPath, + this.thumbnailBytes}); + + final String originalMediaUrl; + + final String? originalMediaPath; + + final Uint8List? originalMediaBytes; + + final String? thumbnailImageUrl; + + final String? thumbnailPath; + + final Uint8List? thumbnailBytes; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/single_chat_history_page/single_chat_history_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/single_chat_history_page/single_chat_history_page.dart new file mode 100644 index 0000000000..918814bd2f --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/single_chat_history_page/single_chat_history_page.dart @@ -0,0 +1,367 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../../../../../domain/conversation/models/conversation.dart'; +import '../../../../../../../domain/message/models/message_type.dart'; +import '../../../../../../../domain/message/services/message_service.dart'; +import '../../../../../../../domain/user/models/contact.dart'; +import '../../../../../../../domain/user/models/group_member.dart'; +import '../../../../../../../domain/user/view_models/logged_in_user_info_view_model.dart'; +import '../../../../../../../infra/data/t_async_data.dart'; +import '../../../../../../l10n/app_localizations.dart'; +import '../../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../../../l10n/view_models/date_format_view_models.dart'; +import '../../../../../../themes/app_theme_extension.dart'; +import '../../../../../../themes/sizes.dart'; +import '../../../../../components/index.dart'; +import '../message.dart'; + +const searchResultLimit = 20; + +class SingleChatHistoryPage extends ConsumerStatefulWidget { + const SingleChatHistoryPage({super.key, required this.conversation}); + + final Conversation conversation; + + @override + ConsumerState createState() => + _SingleChatHistoryPageState(); +} + +class _SingleChatHistoryPageState extends ConsumerState + with SingleTickerProviderStateMixin { + late ScrollController _scrollController; + List _foundMessages = []; + TAsyncData> _loadedMessagesData = const TAsyncData(); + + Int64? _loggedInUserId; + + String? _searchConditionText; + MessageType? _searchConditionMessageType; + + Conversation? _lastConversation; + ChatMessage? _lastFoundMessage; + bool _hasMoreMessages = false; + Future? _currentFindMessagesTask; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController() + ..addListener(() async { + final position = _scrollController.position; + if (position.userScrollDirection != ScrollDirection.reverse) { + return; + } + if (position.atEdge) { + final currentLoadTask = _currentFindMessagesTask; + if (currentLoadTask != null) { + await currentLoadTask; + } else if (_hasMoreMessages) { + await _findMoreMessages(); + } + final foundMessages = _foundMessages; + if (foundMessages.length > (_loadedMessagesData.value?.length ?? 0)) { + _loadedMessagesData = TAsyncData(value: foundMessages.toList()); + if (mounted) { + setState(() {}); + } + } + } else if (position.maxScrollExtent - position.pixels < 64 * 3 && + _hasMoreMessages && + _currentFindMessagesTask == null) { + await _findMoreMessages(); + } + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + _loggedInUserId = ref.watch(loggedInUserViewModel)!.userId; + final appThemeExtension = context.appThemeExtension; + return SizedBox( + width: Sizes.dialogWidthMedium, + height: Sizes.dialogHeightMedium, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + widget.conversation.contact.name, + style: appThemeExtension.dialogTitleTextStyleMedium, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + const TTitleBar( + usePositioned: false, + displayCloseOnly: true, + popOnCloseTapped: true, + ), + ], + ), + Sizes.sizedBoxH8, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: TSearchBar( + hintText: appLocalizations.search, + autofocus: true, + keepFocusOnSubmit: true, + onChanged: _search, + onSubmitted: _search, + debounceTimeout: const Duration(milliseconds: 500), + ), + ), + Sizes.sizedBoxH12, + Flexible( + child: _buildSearchResultTabView(), + ), + ], + ), + ); + } + + Widget _buildSearchResultTabView() { + assert(!_loadedMessagesData.isLoading, + '_foundMessagesData must not be loading'); + final loadedMessages = _loadedMessagesData.value; + if (loadedMessages == null) { + return const TEmpty(); + } else if (loadedMessages.isEmpty) { + return const TEmptyResult(); + } + final appLocalizations = ref.watch(appLocalizationsViewModel); + // ignore: non_constant_identifier_names + final dateFormat_yMdjm = ref.watch(dateFormatViewModel_yMdjm); + // ignore: non_constant_identifier_names + final dateFormat_Mdjm = ref.watch(dateFormatViewModel_Mdjm); + // ignore: non_constant_identifier_names + final dateFormat_jm = ref.watch(dateFormatViewModel_jm); + final messageIdToIndex = { + for (final (index, message) in loadedMessages.indexed) + message.messageId: index + }; + final contact = widget.conversation.contact; + final groupMemberIdToMember = contact is GroupContact + ? Map.fromIterable( + contact.members, + key: (member) => (member as GroupMember).userId, + value: (member) => member as GroupMember, + ) + : null; + final now = DateTime.now(); + final count = loadedMessages.length; + return ListView.builder( + shrinkWrap: true, + controller: _scrollController, + itemCount: count, + reverse: true, + findChildIndexCallback: (key) { + final messageId = (key as ValueKey).value as Int64; + return messageIdToIndex[messageId]; + }, + itemBuilder: (context, index) { + final message = loadedMessages[index]; + switch (contact) { + case SystemContact(): + return _buildMessageTile( + appLocalizations: appLocalizations, + dateFormat_yMdjm: dateFormat_yMdjm, + dateFormat_Mdjm: dateFormat_Mdjm, + dateFormat_jm: dateFormat_jm, + now: now, + id: contact.id, + senderName: contact.name, + senderImage: contact.image, + message: message); + case UserContact(): + return _buildMessageTile( + appLocalizations: appLocalizations, + dateFormat_yMdjm: dateFormat_yMdjm, + dateFormat_Mdjm: dateFormat_Mdjm, + dateFormat_jm: dateFormat_jm, + now: now, + id: contact.id, + senderName: contact.name, + senderImage: contact.image, + message: message); + case GroupContact(): + final groupMember = groupMemberIdToMember![message.senderId]; + if (groupMember == null) { + return _buildMessageTile( + appLocalizations: appLocalizations, + dateFormat_yMdjm: dateFormat_yMdjm, + dateFormat_Mdjm: dateFormat_Mdjm, + dateFormat_jm: dateFormat_jm, + now: now, + id: contact.id, + senderName: message.senderId.toString(), + // TODO: use default avatar + message: message); + } + return _buildMessageTile( + appLocalizations: appLocalizations, + dateFormat_yMdjm: dateFormat_yMdjm, + dateFormat_Mdjm: dateFormat_Mdjm, + dateFormat_jm: dateFormat_jm, + now: now, + id: contact.id, + senderName: groupMember.name, + senderImage: groupMember.image, + message: message); + } + }); + } + + Widget _buildMessageTile({ + required AppLocalizations appLocalizations, + // ignore: non_constant_identifier_names + required DateFormat dateFormat_yMdjm, + // ignore: non_constant_identifier_names + required DateFormat dateFormat_Mdjm, + // ignore: non_constant_identifier_names + required DateFormat dateFormat_jm, + required DateTime now, + required Int64 id, + required String senderName, + ImageProvider? senderImage, + required ChatMessage message, + }) { + final timestamp = message.timestamp; + return TListTile( + key: ValueKey(message.messageId), + height: null, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + backgroundColor: Colors.white, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TAvatar(id: id, name: senderName, image: senderImage), + Sizes.sizedBoxW8, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 8, + children: [ + Text(senderName), + Text( + now.year == timestamp.year + ? (now.month == timestamp.month && + now.day == timestamp.day) + ? dateFormat_jm.format(timestamp) + : dateFormat_Mdjm.format(timestamp) + : dateFormat_yMdjm.format(timestamp), + ) + ], + ), + // Sizes.sizedBoxH8, + // TODO + Text(message.text!), + ], + ), + ), + ], + )); + } + + Future _search(String text) async { + final loggedInUserId = _loggedInUserId!; + // Note that there is no need to dispose the loading status. + final conversation = widget.conversation; + final (groupId, participantIds) = switch (conversation) { + final GroupConversation c => (c.contact.groupId, null), + final UserConversation c => (null, [loggedInUserId, c.contact.userId]), + final SystemConversation c => (null, [loggedInUserId, loggedInUserId]), + }; + var newFoundMessages = + await ref.read(messageServiceProvider)!.searchMessages( + loggedInUserId: loggedInUserId, + groupId: groupId, + participantIds: participantIds, + text: text, + limit: searchResultLimit + 1, + messageType: _searchConditionMessageType, + ); + final count = newFoundMessages.length; + _hasMoreMessages = count > searchResultLimit; + if (count > searchResultLimit) { + newFoundMessages = newFoundMessages.sublist(0, searchResultLimit); + } + _lastConversation = conversation; + _searchConditionText = text; + _lastFoundMessage = newFoundMessages.lastOrNull; + _foundMessages = newFoundMessages; + _loadedMessagesData = TAsyncData(value: newFoundMessages.toList()); + if (mounted) { + if (_scrollController.hasClients) { + _scrollController.jumpTo(0); + } + setState(() {}); + } + } + + Future _findMoreMessages() async { + final userId = _loggedInUserId; + if (userId == null) { + return; + } + final currentFindMessagesTask = _findMoreMessages0(userId); + _currentFindMessagesTask = currentFindMessagesTask; + try { + await currentFindMessagesTask; + } finally { + _currentFindMessagesTask = null; + } + } + + Future _findMoreMessages0(Int64 loggedInUserId) async { + // Note that there is no need to dispose the loading status. + final (groupId, participantIds) = switch (_lastConversation!) { + final GroupConversation c => (c.contact.groupId, null), + final UserConversation c => (null, [loggedInUserId, c.contact.userId]), + final SystemConversation c => (null, [loggedInUserId, loggedInUserId]), + }; + var newFoundMessages = + await ref.read(messageServiceProvider)!.searchMessages( + loggedInUserId: loggedInUserId, + idStart: _lastFoundMessage?.messageId, + groupId: groupId, + participantIds: participantIds, + text: _searchConditionText, + messageType: _searchConditionMessageType, + limit: searchResultLimit + 1, + createdDateEnd: _lastFoundMessage?.timestamp, + ); + final count = newFoundMessages.length; + _hasMoreMessages = count > searchResultLimit; + if (count > searchResultLimit) { + newFoundMessages = newFoundMessages.sublist(0, searchResultLimit); + } + _lastFoundMessage = newFoundMessages.lastOrNull; + _foundMessages.addAll(newFoundMessages); + } +} + +Future showSingleChatHistoryDialog( + {required BuildContext context, required Conversation conversation}) => + showCustomTDialog( + routeName: '/single-chat-history-dialog', + context: context, + child: SingleChatHistoryPage(conversation: conversation)); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/sticker_picker/emoji_picker_pane.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/sticker_picker/emoji_picker_pane.dart new file mode 100644 index 0000000000..2f0c3321ed --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/sticker_picker/emoji_picker_pane.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +import '../../../../../../themes/index.dart'; + +import '../../../../../components/index.dart'; + +const _emoticons = [ + '😀', + '😁', + '😂', + '😃', + '😄', + '😅', + '😆', + '😇', + '😈', + '😉', + '😊', + '😋', + '😌', + '😍', + '😎', + '😏', + '😐', + '😑', + '😒', + '😓', + '😔', + '😕', + '😖', + '😗', + '😘', + '😙', + '😚', + '😛', + '😜', + '😝', + '😞', + '😟', + '😠', + '😡', + '😢', + '😣', + '😤', + '😥', + '😦', + '😧', + '😨', + '😩', + '😪', + '😫', + '😬', + '😭', + '😮', + '😯', + '😰', + '😱', + '😲', + '😳', + '😴', + '😵', + '😶', + '😷', + '😸', + '😹', + '😺', + '😻', + '😼', + '😽', + '😾', + '😿', + '🙀', + '🙁', + '🙂', + '🙃', + '🙄', + '🙅', + '🙆', + '🙇', + '🙈', + '🙉', + '🙊', + '🙋', + '🙌', + '🙍', + '🙎', + '🙏' +]; + +const containerColorHovered = Color.fromARGB(255, 242, 242, 242); + +class EmojiPickerPane extends StatelessWidget { + const EmojiPickerPane({ + super.key, + required this.onEmojiSelected, + }); + + final ValueChanged onEmojiSelected; + + @override + Widget build(BuildContext context) => GridView.builder( + itemCount: _emoticons.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 10, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemBuilder: (context, index) { + final text = _emoticons[index]; + return TTextButton( + text: text, + textStyle: TextStyle( + fontFamily: Fonts.emojiFontFamily, + fontFamilyFallback: Fonts.emojiFontFamilyFallback, + fontSize: 26, + ), + containerPadding: EdgeInsets.zero, + containerColor: Colors.white, + containerColorHovered: containerColorHovered, + onTap: () { + onEmojiSelected(text); + }); + }, + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/sticker_picker/sticker_picker.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/sticker_picker/sticker_picker.dart new file mode 100644 index 0000000000..9cffad37c7 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/chat_session_pane/sticker_picker/sticker_picker.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../../../../../infra/env/env_vars.dart'; +import '../../../../../../themes/index.dart'; + +import '../../../../../components/giphy/client/models/gif.dart'; +import '../../../../../components/index.dart'; +import 'emoji_picker_pane.dart'; + +const _containerColorHovered = Color.fromARGB(255, 242, 242, 242); +final _isGiphyEnabled = EnvVars.giphyApiKey.isNotBlank; + +class StickerPicker extends StatefulWidget { + const StickerPicker( + {super.key, + required this.onGiphyGifSelected, + required this.onEmojiSelected}); + + final ValueChanged onGiphyGifSelected; + final ValueChanged onEmojiSelected; + + @override + State createState() => _StickerPickerState(); +} + +class _StickerPickerState extends State { + _Tab _currentTab = _Tab.emoji; + + @override + Widget build(BuildContext context) => Material( + color: Colors.transparent, + child: SizedBox( + width: Sizes.stickerPickerWidth, + height: Sizes.stickerPickerHeight, + child: DecoratedBox( + decoration: context.appThemeExtension.popupDecoration, + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: _isGiphyEnabled + ? Column( + children: [ + Flexible( + child: Padding( + padding: Sizes.paddingH16, + child: TLazyIndexedStack( + index: switch (_currentTab) { + _Tab.emoji => 0, + _Tab.giphy => 1, + }, + children: [ + EmojiPickerPane( + onEmojiSelected: widget.onEmojiSelected, + ), + GiphyPicker( + onSelected: widget.onGiphyGifSelected, + ), + ], + ), + ), + ), + const THorizontalDivider( + color: _containerColorHovered, + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4, horizontal: 16), + child: Row( + spacing: 16, + children: [ + TIconButton( + iconData: Symbols.emoji_emotions_rounded, + containerSize: const Size.square(32), + containerColorHovered: _containerColorHovered, + containerPadding: EdgeInsets.zero, + onTap: () { + _currentTab = _Tab.emoji; + setState(() {}); + }, + ), + TIconButton( + iconData: Symbols.search_rounded, + containerSize: const Size.square(32), + containerColorHovered: _containerColorHovered, + containerPadding: EdgeInsets.zero, + onTap: () { + _currentTab = _Tab.giphy; + setState(() {}); + }, + ) + ], + ), + ) + ], + ) + : EmojiPickerPane( + onEmojiSelected: widget.onEmojiSelected, + ), + ), + ), + ), + ); +} + +enum _Tab { emoji, giphy } diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/sub_navigation_rail/conversation_tile.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/sub_navigation_rail/conversation_tile.dart new file mode 100644 index 0000000000..f7480ee8b2 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/sub_navigation_rail/conversation_tile.dart @@ -0,0 +1,466 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +import '../../../../../../domain/conversation/models/conversation.dart'; +import '../../../../../../domain/conversation/models/conversation_settings.dart'; +import '../../../../../../domain/conversation/services/conversation_service.dart'; +import '../../../../../../domain/message/models/message_type.dart'; +import '../../../../../../domain/message/repositories/message_repository.dart'; +import '../../../../../../domain/user/models/contact.dart'; +import '../../../../../../domain/user/view_models/logged_in_user_info_view_model.dart'; +import '../../../../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../../../../infra/collection/list_holder.dart'; +import '../../../../../l10n/app_localizations.dart'; +import '../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../../l10n/view_models/date_format_view_models.dart'; +import '../../../../../themes/index.dart'; +import '../../../../components/index.dart'; +import '../chat_session_pane/message.dart'; +import '../view_models/conversations_view_model.dart'; + +const _messageIconSize = 16.0; +const _fontWeightBold = FontWeight.w600; + +class ConversationTile extends ConsumerStatefulWidget { + ConversationTile({ + super.key, + required this.item, + this.conversationSettings, + this.highlighted = false, + this.selected = false, + required this.onTap, + required this.onSecondaryTap, + required this.onDeleted, + }) : isSearching = item is ConversationTileItemForSearchMode; + + final ConversationTileItem item; + final ConversationSettings? conversationSettings; + + final bool highlighted; + final bool selected; + + final GestureTapCallback onTap; + final GestureTapCallback onSecondaryTap; + final VoidCallback onDeleted; + + final bool isSearching; + + @override + ConsumerState createState() => _ConversationTileState(); +} + +class _ConversationTileState extends ConsumerState { + bool _useBoldText = false; + + @override + Widget build(BuildContext context) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + final appThemeExtension = context.appThemeExtension; + _useBoldText = !widget.isSearching && + (widget.item as ConversationTileItemForNormalMode) + .conversation + .unreadMessageCount > + 0; + final conversationId = widget.item.conversationId; + final settings = widget.conversationSettings; + final pinned = settings?.pinned ?? false; + final enableNewMessageNotification = + settings?.enableNewMessageNotification ?? false; + return Stack( + children: [ + TListTile( + onTap: widget.onTap, + onSecondaryTapUp: (details) { + _showConversationContextMenu( + context, + details.globalPosition, + appLocalizations, + conversationId, + pinned, + enableNewMessageNotification); + }, + focused: widget.selected, + backgroundColor: widget.highlighted + ? appThemeExtension.tileBackgroundHighlightedColor + : appThemeExtension.tileBackgroundColor, + padding: + // use more right padding to reserve space for scrollbar + // TODO: adapt the padding to not hide part of text (e.g. contact name). + const EdgeInsets.only(left: 10, right: 14, top: 12, bottom: 12), + child: Row(mainAxisSize: MainAxisSize.min, spacing: 8, children: [ + _buildAvatar(), + Expanded( + child: _buildConversation(context, appThemeExtension, + appLocalizations, enableNewMessageNotification)) + ]), + ), + if (widget.conversationSettings?.pinned ?? false) + const Positioned( + top: 2, + left: 2, + child: CustomPaint( + painter: _PinnedConversationMarkerPainter(), + size: Size.square(12), + )), + ], + ); + } + + void _showConversationContextMenu( + BuildContext context, + Offset globalPosition, + AppLocalizations appLocalizations, + IntListHolder conversationId, + bool pinned, + bool enableNewMessageNotification) { + showPopup( + context: context, + targetGlobalRect: + Rect.fromLTWH(globalPosition.dx, globalPosition.dy, 0, 0), + targetAnchor: Alignment.topLeft, + followerAnchor: Alignment.topLeft, + follower: TMenu( + dense: true, + padding: Sizes.paddingV8H16, + entries: [ + TMenuEntry( + value: 'pin', + label: pinned ? appLocalizations.unpin : appLocalizations.pin, + onSelected: () { + ref.read(conversationServiceProvider)!.updateSettingPinned( + conversationId: conversationId, + newValue: !pinned, + contact: widget.item.contact, + ); + hideAllPopups(); + }, + ), + TMenuEntry( + value: 'enableNewMessageNotification', + label: enableNewMessageNotification + ? appLocalizations.disableNewMessageNotification + : appLocalizations.enableNewMessageNotification, + onSelected: () { + ref + .read(conversationServiceProvider)! + .updateSettingEnableNewMessageNotification( + conversationId: conversationId, + newValue: !enableNewMessageNotification, + contact: widget.item.contact, + ); + hideAllPopups(); + }, + ), + TMenuEntry.separator, + TMenuEntry( + value: 'deleteChat', + label: appLocalizations.deleteChat, + onSelected: () { + hideAllPopups(); + showAlertTDialog( + routeName: 'deleteChat', + context: context, + contentTextProvider: (appLocalizations) => + appLocalizations.deleteChat, + confirmAction: TDialogAction( + style: TDialogActionStyle.danger, + textProvider: (appLocalizations) => appLocalizations.delete, + onPressed: () { + final messageRepository = + ref.read(messageRepositoryProvider)!; + final item = widget.item; + final conversationId = item.conversationId; + final contact = item.contact; + ref + .read(conversationsDataViewModel.notifier) + .deleteConversation(conversationId); + switch (contact) { + case GroupContact(): + messageRepository.delete(groupId: contact.groupId); + case UserContact(): + messageRepository.delete(participantIds: [ + ref.read(loggedInUserViewModel)!.userId, + contact.userId, + ]); + case SystemContact(): + switch (contact.type) { + case SystemContactType.fileTransfer: + final userId = + ref.read(loggedInUserViewModel)!.userId; + messageRepository.delete(participantIds: [ + userId, + userId, + ]); + case SystemContactType.requestNotification: + throw UnsupportedError('unsupported'); + } + } + widget.onDeleted(); + return true; + }, + )); + }, + ), + ], + ), + ); + } + + Stack _buildAvatar() { + final contact = widget.item.contact; + return Stack(clipBehavior: Clip.none, children: [ + TAvatar( + id: contact.id, + name: contact.name, + image: contact.image, + presence: contact is UserContact ? contact.presence : UserPresence.none, + ), + ]); + } + + Column _buildConversation( + BuildContext context, + AppThemeExtension appThemeExtension, + AppLocalizations appLocalizations, + bool enableNewMessageNotification) { + final item = widget.item; + final String? draft; + final latestMessage = item.latestMessage; + if (item is ConversationTileItemForNormalMode) { + draft = item.conversation.draft; + } else { + draft = null; + } + final now = DateTime.now(); + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 12, + children: [ + Flexible( + child: Text.rich( + TextSpan(children: item.nameTextSpans), + style: _useBoldText + ? const TextStyle(fontWeight: _fontWeightBold) + : null, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + )), + _buildDatetime( + latestMessage, now, appLocalizations, appThemeExtension) + ], + )), + _buildMessage(draft, appThemeExtension, appLocalizations, + latestMessage, enableNewMessageNotification) + ]); + } + + Text _buildDatetime(ChatMessage? latestMessage, DateTime now, + AppLocalizations appLocalizations, AppThemeExtension appThemeExtension) { + final timestamp = latestMessage?.timestamp; + return Text( + timestamp == null || widget.isSearching + ? '' + : DateUtils.isSameDay(now, timestamp) + ? ref.watch(dateFormatViewModel_jm).format(timestamp) + : DateUtils.isSameMonth(now, timestamp) + ? ref.watch(dateFormatViewModel_Md).format(timestamp) + : DateUtils.isSameDay( + now.subtract(const Duration(days: 1)), timestamp) + ? appLocalizations.yesterday + : ref.watch(dateFormatViewModel_yMd).format(timestamp), + style: _useBoldText + ? appThemeExtension.conversationTileTimestampTextStyle + .copyWith(fontWeight: _fontWeightBold) + : appThemeExtension.conversationTileTimestampTextStyle, + strutStyle: const StrutStyle(fontSize: 14, forceStrutHeight: true), + ); + } + + Row _buildMessage( + String? draft, + AppThemeExtension appThemeExtension, + AppLocalizations localizations, + ChatMessage? latestMessage, + bool enableNewMessageNotification) { + final children = draft?.isNotBlank ?? false + ? [ + // Note: the draft is always a text instead of image, video, or etc as + // we haven't supported embedded images, videos, and etc, into a message. + TextSpan( + text: '[${localizations.draft}]', + style: _useBoldText + ? appThemeExtension.conversationTileHighlightedTextStyle + .copyWith(fontWeight: _fontWeightBold) + : appThemeExtension.conversationTileHighlightedTextStyle), + TextSpan(text: draft), + ] + : latestMessage == null + ? [] + : switch (latestMessage.type) { + MessageType.text => [TextSpan(text: latestMessage.text)], + MessageType.image => [ + const WidgetSpan( + child: Icon(Symbols.image_rounded, + size: _messageIconSize)), + TextSpan( + text: localizations.image, + ) + ], + MessageType.file => [ + const WidgetSpan( + child: Icon(Symbols.description_rounded, + size: _messageIconSize)), + TextSpan( + text: localizations.file, + ) + ], + MessageType.video => [ + const WidgetSpan( + child: Icon(Symbols.video_file_rounded, + size: _messageIconSize)), + TextSpan( + text: localizations.video, + ) + ], + MessageType.audio => [ + const WidgetSpan( + child: Icon(Symbols.audio_file_rounded, + size: _messageIconSize)), + TextSpan( + text: localizations.audio, + ) + ], + MessageType.youtube => [ + const WidgetSpan( + child: Icon(Symbols.smart_display_rounded, + size: _messageIconSize)), + TextSpan( + text: localizations.youtube, + ) + ], + }; + final strutStyle = StrutStyle( + fontSize: appThemeExtension.conversationTileMessageTextStyle.fontSize!, + forceStrutHeight: true); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: widget.isSearching + ? Text.rich( + TextSpan( + children: + (widget.item as ConversationTileItemForSearchMode) + .messageTextSpans), + style: appThemeExtension.conversationTileMessageTextStyle, + strutStyle: strutStyle, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + ) + : Text.rich( + TextSpan(children: children), + style: _useBoldText + ? appThemeExtension.conversationTileMessageTextStyle + .copyWith(fontWeight: _fontWeightBold) + : appThemeExtension.conversationTileMessageTextStyle, + strutStyle: strutStyle, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + ), + ), + if (enableNewMessageNotification) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon(Symbols.notifications_off_rounded, + size: 14, + // TODO + color: + appThemeExtension.conversationTileMessageTextStyle.color!), + ) + ], + ); + } +} + +const _leg = 2.0; +final _hypotenuse = sqrt(2 * _leg * _leg); + +class _PinnedConversationMarkerPainter extends CustomPainter { + const _PinnedConversationMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final dimension = size.width; + final height = _hypotenuse / 2; + // radius = (height / 2) + (width^2 / (8 * height)) + final radius = Radius.circular(height / 2 + dimension / 8); + + canvas.drawPath( + Path() + ..moveTo(_leg, 0) + ..arcToPoint(const Offset(0, _leg), radius: radius, clockwise: false) + ..lineTo(0, dimension - _leg) + ..arcToPoint(Offset(_leg, dimension), + radius: radius, clockwise: false) + ..lineTo(dimension, _leg) + ..arcToPoint(Offset(dimension - _leg, 0), + radius: radius, clockwise: false) + ..lineTo(_leg, 0) + ..close(), + Paint() + ..color = const Color.fromARGB(255, 170, 207, 244) + ..style = PaintingStyle.fill); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +sealed class ConversationTileItem { + const ConversationTileItem( + {required this.conversationId, + required this.contact, + required this.nameTextSpans, + this.latestMessage}); + + final IntListHolder conversationId; + final Contact contact; + final List nameTextSpans; + final ChatMessage? latestMessage; +} + +class ConversationTileItemForNormalMode extends ConversationTileItem { + ConversationTileItemForNormalMode({ + required super.nameTextSpans, + required this.conversation, + }) : super( + conversationId: conversation.id, + contact: conversation.contact, + latestMessage: conversation.messages.lastOrNull); + + final Conversation conversation; +} + +class ConversationTileItemForSearchMode extends ConversationTileItem { + const ConversationTileItemForSearchMode( + {required super.conversationId, + required super.contact, + required super.nameTextSpans, + required super.latestMessage, + required this.count, + required this.messageTextSpans}); + + final int count; + final List messageTextSpans; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/sub_navigation_rail/conversation_tiles.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/sub_navigation_rail/conversation_tiles.dart new file mode 100644 index 0000000000..e025b2b7de --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/sub_navigation_rail/conversation_tiles.dart @@ -0,0 +1,93 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../domain/conversation/models/conversation.dart'; +import '../../../../../../domain/conversation/view_models/id_to_conversation_settings_view_model.dart'; +import '../../../../../../domain/user/models/index.dart'; + +import '../../../../../../infra/collection/list_holder.dart'; +import 'conversation_tile.dart'; + +class ConversationTiles extends ConsumerStatefulWidget { + const ConversationTiles({ + Key? key, + required this.conversationTileItems, + this.highlightedConversationTileItemIndex, + this.selectedConversationId, + required this.conversationTilesScrollController, + required this.onConversationTilesBuildContextUpdated, + required this.onConversationTileItemSelected, + required this.onConversationDeleted, + }) : super(key: key); + + final List conversationTileItems; + final int? highlightedConversationTileItemIndex; + final IntListHolder? selectedConversationId; + final ScrollController conversationTilesScrollController; + + final ValueChanged onConversationTilesBuildContextUpdated; + final ValueChanged onConversationTileItemSelected; + final ValueChanged onConversationDeleted; + + @override + ConsumerState createState() => _ConversationTilesState(); +} + +class _ConversationTilesState extends ConsumerState { + @override + Widget build(BuildContext context) { + final idToConversationSettings = + ref.watch(idToConversationSettingsViewModel); + widget.onConversationTilesBuildContextUpdated(null); + // Don't use "ScrollablePositionedList" because it's buggy. + // e.g. https://github.com/google/flutter.widgets/issues/276 + final items = widget.conversationTileItems; + final itemCount = items.length; + final recordIdToIndex = { + for (var i = 0; i < itemCount; i++) items[i].contact.recordId: i + }; + return ListView.builder( + controller: widget.conversationTilesScrollController, + addAutomaticKeepAlives: false, + padding: EdgeInsets.zero, + itemCount: itemCount, + prototypeItem: ConversationTile( + item: ConversationTileItemForNormalMode( + conversation: UserConversation( + contact: UserContact( + userId: Int64.ZERO, name: '', relationshipGroupId: Int64.ZERO), + messages: [], + ), + nameTextSpans: [], + ), + onTap: () {}, + onSecondaryTap: () {}, + onDeleted: () {}, + ), + findChildIndexCallback: (key) => + recordIdToIndex[(key as ValueKey).value], + itemBuilder: (context, index) { + widget.onConversationTilesBuildContextUpdated(context); + final item = items[index]; + final conversationId = item.conversationId; + return ConversationTile( + key: ValueKey(item.contact.recordId), + item: item, + conversationSettings: idToConversationSettings[conversationId], + selected: widget.selectedConversationId == conversationId, + highlighted: widget.highlightedConversationTileItemIndex == index, + onTap: () { + widget.onConversationTileItemSelected(item); + }, + onSecondaryTap: () { + // TODO + }, + onDeleted: () { + widget.onConversationDeleted(conversationId); + }, + ); + }, + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/sub_navigation_rail/sub_navigation_rail.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/sub_navigation_rail/sub_navigation_rail.dart new file mode 100644 index 0000000000..19abf8ca41 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/sub_navigation_rail/sub_navigation_rail.dart @@ -0,0 +1,478 @@ +import 'dart:async'; + +import 'package:async/async.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../../../domain/conversation/models/conversation.dart'; +import '../../../../../../domain/conversation/services/conversation_service.dart'; +import '../../../../../../domain/message/models/message_delivery_status.dart'; +import '../../../../../../domain/message/repositories/message_repository.dart'; +import '../../../../../../domain/user/models/contact.dart'; +import '../../../../../../domain/user/view_models/logged_in_user_info_view_model.dart'; +import '../../../../../../infra/collection/list_holder.dart'; +import '../../../../../../infra/data/t_async_data.dart'; +import '../../../../../../infra/navigation/navigation_utils.dart'; +import '../../../../../../infra/ui/scroll_utils.dart'; +import '../../../../../../infra/ui/text_utils.dart'; +import '../../../../../l10n/app_localizations.dart'; +import '../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../../themes/app_theme_extension.dart'; +import '../../../../../themes/sizes.dart'; +import '../../../../components/index.dart'; +import '../../contacts_page/view_models/contacts_view_model.dart'; +import '../../create_group_page/create_group_page.dart'; +import '../../new_relationship_page/new_relationship_page.dart'; +import '../chat_session_pane/message.dart'; +import '../view_models/conversations_view_model.dart'; +import '../view_models/selected_conversation_view_model.dart'; +import 'conversation_tile.dart'; +import 'conversation_tiles.dart'; + +class SubNavigationRail extends ConsumerStatefulWidget { + const SubNavigationRail({super.key}); + + @override + ConsumerState createState() => _SubNavigationRailState(); +} + +class _SubNavigationRailState extends ConsumerState { + late TextEditingController _searchBarTextEditingController; + late FocusNode _searchBarFocusNode; + CancelableOperation? _searchConversationTask; + late ScrollController _conversationTilesScrollController; + BuildContext? _conversationTilesBuildContext; + + late TAsyncData> _conversationsData; + List _conversationTileItems = []; + Conversation? _selectedConversation; + int? _highlightedConversationTileItemIndex; + + late Int64 _loggedInUserId; + + final _SearchData _searchData = _SearchData(); + + @override + void initState() { + super.initState(); + _searchBarTextEditingController = TextEditingController(); + _searchBarFocusNode = FocusNode() + ..addListener( + _updateHighlightedConversationTileItemIndexOnSearchBarFocusChanged); + _conversationTilesScrollController = ScrollController(); + ref.listenManual( + fireImmediately: true, + conversationsDataViewModel, + (previous, next) { + _conversationsData = next; + if (_searchData.isSearching) { + _onSearchTextUpdated(true, _searchData.searchText); + } else { + setState(() {}); + } + }, + ); + } + + @override + void dispose() { + _searchBarTextEditingController.dispose(); + _searchBarFocusNode.dispose(); + _conversationTilesScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + _selectedConversation = ref.watch(selectedConversationViewModel); + _loggedInUserId = ref.watch(loggedInUserViewModel)!.userId; + + final theme = context.theme; + final appThemeExtension = theme.appThemeExtension; + final previousSelectedConversationId = _selectedConversation?.id; + final conversationId = _selectedConversation?.id; + final conversations = _conversationsData.value ?? []; + if (conversationId != null && + previousSelectedConversationId != conversationId) { + final conversationIndex = + conversations.indexWhere((element) => element.id == conversationId); + if (conversationIndex >= 0) { + _scrollTo(conversationIndex); + } + } + + final searchData = _searchData; + if (searchData.isSearching) { + _conversationTileItems = searchData.searchResults + .expand((item) { + final latestMessage = item.latestMessage; + final nameTextSpans = TextUtils.highlightSearchText( + text: item.contact.name, + searchText: searchData.searchText, + searchTextStyle: + appThemeExtension.conversationTileHighlightedTextStyle); + final messageTextSpans = switch (item.count) { + 0 => [], + 1 => TextUtils.highlightSearchText( + // The UI only show found text messages, + // so the text should not be null. + text: item.latestMessage.text!, + searchText: searchData.searchText, + searchTextStyle: context + .appThemeExtension.conversationTileHighlightedTextStyle), + _ => [TextSpan(text: appLocalizations.relatedMessages(item.count))] + }; + return [ + ConversationTileItemForSearchMode( + conversationId: item.conversationId, + contact: item.contact, + latestMessage: latestMessage, + count: item.count, + nameTextSpans: nameTextSpans, + messageTextSpans: messageTextSpans, + ) + ]; + }).toList(); + } else { + _conversationTileItems = conversations + .expand((conversation) => [ + ConversationTileItemForNormalMode( + conversation: conversation, + nameTextSpans: [TextSpan(text: conversation.contact.name)], + ) + ]) + .toList(); + } + return _buildView(theme, appThemeExtension, appLocalizations); + } + + void _scrollTo(int itemIndex) { + SchedulerBinding.instance.addPostFrameCallback((_) { + final itemContext = _conversationTilesBuildContext; + if (itemContext == null) { + return; + } + final renderObject = + itemContext.findRenderObject() as RenderSliverFixedExtentBoxAdaptor; + final itemHeight = renderObject.itemExtent!; + ScrollUtils.ensureVisible( + controller: _conversationTilesScrollController, + viewportDimension: renderObject.constraints.viewportMainAxisExtent, + itemOffset: itemHeight * itemIndex, + itemHeight: itemHeight); + }); + } + + void _selectConversationWhenNotSearching(Conversation conversation) { + conversation.unreadMessageCount = 0; + ref.read(selectedConversationViewModel.notifier).update(conversation); + unawaited(ref + .read(conversationServiceProvider)! + .resetSharedUnreadMessageCount( + userId: conversation is UserConversation + ? conversation.contact.userId + : conversation is SystemConversation + ? _loggedInUserId + : null, + groupId: conversation is GroupConversation + ? conversation.contact.groupId + : null)); + } + + void _selectConversationWhenSearching(IntListHolder conversationId) { + _searchBarTextEditingController.clear(); + _onSearchTextUpdated(false, ''); + // TODO: showChatHistoryDialog + } + + void _onConversationDeleted(IntListHolder conversationId) { + _onSearchTextUpdated(true, _searchData.searchText); + } + + Future _onSearchTextUpdated(bool forceUpdate, String value) async { + const searchResultLimit = 20; + final newSearchText = value.toLowerCase().trim(); + final newIsSearching = newSearchText.isNotEmpty; + _highlightedConversationTileItemIndex = null; + + if (newIsSearching) { + if (forceUpdate || newSearchText != _searchData.searchText) { + final contacts = ref.read(contactsDataViewModel).value ?? []; + final contactIdToContact = { + for (final contact in contacts) + (contact.id, contact is GroupContact): contact, + }; + _searchData.searchText = newSearchText; + await _searchConversationTask?.cancel(); + _searchConversationTask = CancelableOperation.fromFuture(Future( + () => ref + .read(messageRepositoryProvider)! + .countAndSearchLatestMessage( + text: newSearchText, limit: searchResultLimit + 1), + )).then( + (results) { + _searchData + ..isSearching = true + ..hasMoreSearchResultItems = results.length > searchResultLimit + ..searchResults = + results.take(searchResultLimit).expand<_SearchResul>( + (result) { + final messageRecord = result.message; + final isGroupMessage = messageRecord.isGroupMessage; + final contact = contactIdToContact[( + messageRecord.contactId, + isGroupMessage + )]; + if (contact == null) { + return []; + } + final senderId = messageRecord.senderId; + final message = ChatMessage.parse( + messageId: messageRecord.id, + senderId: senderId, + sentByMe: senderId == _loggedInUserId, + isGroupMessage: isGroupMessage, + text: messageRecord.txt, + timestamp: messageRecord.createdDate, + status: MessageDeliveryStatus.delivered); + return [ + _SearchResul( + conversationId: Conversation.generateId( + groupId: isGroupMessage ? contact.id : null, + userId: isGroupMessage ? null : contact.id, + ), + contact: contact, + latestMessage: message, + count: result.count) + ]; + }, + ).toList(); + if (mounted) { + setState(() {}); + } + return null; + }, + ); + } + } else { + _searchData.reset(); + setState(() {}); + } + } + + void _onSearchSubmitted() { + final itemIndex = _highlightedConversationTileItemIndex; + final items = _conversationTileItems; + if (itemIndex != null && itemIndex < items.length) { + final item = items[itemIndex]; + _selectConversationWhenSearching(item.conversationId); + } + setState(() {}); + } + + void _updateHighlightedConversationTileItemIndexOnSearchBarFocusChanged() { + if (_searchBarFocusNode.hasFocus) { + final selectedConversationId = _selectedConversation?.id; + if (selectedConversationId == null) { + _highlightedConversationTileItemIndex = null; + setState(() {}); + } else { + final selectedConversationTileItemIndex = + _conversationTileItems.indexWhere( + (item) => item.conversationId == selectedConversationId); + if (selectedConversationTileItemIndex >= 0) { + _highlightedConversationTileItemIndex = + selectedConversationTileItemIndex; + setState(() {}); + } + } + } else { + _highlightedConversationTileItemIndex = null; + setState(() {}); + } + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + final (result, newIndex) = NavigationUtils.navigateByKeyEvent(event, + _conversationTileItems.length, _highlightedConversationTileItemIndex); + if (result == KeyEventResult.handled) { + if (_highlightedConversationTileItemIndex != newIndex) { + _highlightedConversationTileItemIndex = newIndex!; + setState(() {}); + _scrollTo(newIndex); + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.enter) { + if (_highlightedConversationTileItemIndex case final itemIndex?) { + if (_searchData.isSearching) { + _selectConversationWhenSearching( + _searchData.searchResults[itemIndex].conversationId); + } else { + _selectConversationWhenNotSearching( + _conversationsData.value![itemIndex]); + } + } + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } +} + +extension _SubNavigationRailView on _SubNavigationRailState { + Widget _buildView(ThemeData theme, AppThemeExtension appThemeExtension, + AppLocalizations appLocalizations) => + Focus( + onKeyEvent: _onKeyEvent, + child: Padding( + padding: EdgeInsets.only( + right: Sizes.subNavigationRailDividerSize.thickness), + child: ColoredBox( + color: appThemeExtension.tileBackgroundColor, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // todo: use independent widget + _buildSearchBar(appThemeExtension, appLocalizations), + if (_conversationsData.isLoading) _buildLoadingIndicator(), + _buildConversationTiles() + ], + ), + ), + ), + ); + + Widget _buildLoadingIndicator() => const SizedBox( + height: 40, + child: ColoredBox( + color: Color.fromARGB(255, 237, 237, 237), + child: Align( + alignment: AlignmentDirectional.center, + child: CupertinoActivityIndicator(radius: 8), + ), + ), + ); + + Widget _buildSearchBar(AppThemeExtension appThemeExtension, + AppLocalizations appLocalizations) => + SizedBox( + height: Sizes.homePageHeaderHeight, + child: ColoredBox( + color: appThemeExtension.subNavigationRailSearchBarBackgroundColor, + child: Padding( + padding: Sizes.subNavigationRailPadding, + child: Center( + child: Row( + spacing: 8, + children: [ + Expanded( + // todo: adapt height + child: TSearchBar( + textEditingController: _searchBarTextEditingController, + focusNode: _searchBarFocusNode, + hintText: appLocalizations.search, + onChanged: (value) => _onSearchTextUpdated(false, value), + onSubmitted: (_) => _onSearchSubmitted(), + ), + ), + TMenuPopup( + constrainFollowerWithTargetWidth: false, + targetAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topLeft, + offset: const Offset(0, 8), + entries: [ + TMenuEntry( + value: 0, + label: appLocalizations.addContact, + onSelected: () { + showNewRelationshipDialog(context, true); + }, + ), + TMenuEntry( + value: 1, + label: appLocalizations.joinGroup, + onSelected: () { + showNewRelationshipDialog(context, false); + }, + ), + TMenuEntry( + value: 2, + label: appLocalizations.createGroup, + onSelected: () { + showCreateGroupDialog(context: context); + }, + ), + ], + anchor: const TIconButton( + iconData: Symbols.add_rounded, + iconSize: 20, + // todo: adapt height + containerSize: Size(30, 30), + containerColor: Color.fromARGB(255, 226, 226, 226), + containerColorHovered: + Color.fromARGB(255, 209, 209, 209), + )) + ], + ), + ), + ), + ), + ); + + Widget _buildConversationTiles() => Expanded( + child: ConversationTiles( + conversationTileItems: _conversationTileItems, + highlightedConversationTileItemIndex: + _highlightedConversationTileItemIndex, + selectedConversationId: _selectedConversation?.id, + conversationTilesScrollController: _conversationTilesScrollController, + onConversationTilesBuildContextUpdated: (context) { + _conversationTilesBuildContext ??= context; + }, + onConversationTileItemSelected: (item) { + switch (item) { + case ConversationTileItemForNormalMode(): + _selectConversationWhenNotSearching(item.conversation); + case ConversationTileItemForSearchMode(): + _selectConversationWhenSearching(item.conversationId); + } + }, + onConversationDeleted: _onConversationDeleted, + )); +} + +class _SearchData { + bool isSearching = false; + String searchText = ''; + List<_SearchResul> searchResults = []; + + // TODO: show "load more" button + bool hasMoreSearchResultItems = false; + + void reset() { + isSearching = false; + searchText = ''; + searchResults = []; + hasMoreSearchResultItems = false; + } +} + +class _SearchResul { + const _SearchResul( + {required this.conversationId, + required this.contact, + required this.latestMessage, + required this.count}); + + final IntListHolder conversationId; + final Contact contact; + final ChatMessage latestMessage; + final int count; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/view_models/conversations_view_model.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/view_models/conversations_view_model.dart new file mode 100644 index 0000000000..7a79649e7c --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/view_models/conversations_view_model.dart @@ -0,0 +1,85 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../domain/conversation/models/conversation.dart'; +import '../../../../../../domain/conversation/models/conversation_settings.dart'; +import '../../../../../../domain/conversation/view_models/id_to_conversation_settings_view_model.dart'; +import '../../../../../../infra/collection/list_holder.dart'; +import '../../../../../../infra/core/comparable_utils.dart'; +import '../../../../../../infra/data/t_async_data.dart'; +import '../chat_session_pane/message.dart'; + +class ConversationsDataViewModelNotifier + extends Notifier>> { + @override + TAsyncData> build() { + final idToConversationSettings = + ref.watch(idToConversationSettingsViewModel); + final data = stateOrNull; + if (data == null) { + return const TAsyncData(); + } + if (data.isInitialized) { + return TAsyncData( + value: _sortConversations(data.value!, idToConversationSettings)); + } + return data; + } + + List getConversations() => state.value ?? []; + + void setData(TAsyncData> data) { + if (data.isInitialized) { + data = TAsyncData( + value: _sortConversations( + data.value!, ref.read(idToConversationSettingsViewModel))); + } + state = data; + } + + void addMessage(Conversation conversation, ChatMessage message) { + conversation.messages.add(message); + state = TAsyncData( + value: _sortConversations( + state.value!, ref.read(idToConversationSettingsViewModel))); + } + + void deleteConversation(IntListHolder conversationId) { + state.value! + .removeWhere((conversation) => conversation.id == conversationId); + ref.notifyListeners(); + } + + void addConversation(Conversation newConversation) { + final conversations = state.value!..add(newConversation); + state = TAsyncData( + value: _sortConversations( + conversations, ref.read(idToConversationSettingsViewModel))); + } + + /// Sort by: + /// 1. pinned; + /// 2. file transfer; + /// 3. last message timestamp. + List _sortConversations(List conversations, + Map idToConversationSettings) => + conversations + ..sort((conversation2, conversation1) { + final result = ComparableUtils.compareBool( + idToConversationSettings[conversation1.id]?.pinned ?? false, + idToConversationSettings[conversation2.id]?.pinned ?? false); + if (result != 0) { + return result; + } else if (conversation1.contact.isFileTransfer) { + return 1; + } else if (conversation2.contact.isFileTransfer) { + return -1; + } + return ComparableUtils.compare( + conversation1.messages.lastOrNull?.timestamp, + conversation2.messages.lastOrNull?.timestamp); + }); +} + +final conversationsDataViewModel = NotifierProvider< + ConversationsDataViewModelNotifier, + TAsyncData>>(ConversationsDataViewModelNotifier.new); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/view_models/selected_conversation_view_model.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/view_models/selected_conversation_view_model.dart new file mode 100644 index 0000000000..68c712540d --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/chat_page/view_models/selected_conversation_view_model.dart @@ -0,0 +1,79 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../domain/conversation/models/conversation.dart'; +import '../../../../../../domain/user/models/index.dart'; +import '../../home_page_tab.dart'; +import '../../shared_view_models/home_page_tab_view_model.dart'; +import '../chat_session_pane/message.dart'; +import 'conversations_view_model.dart'; + +class SelectedConversationViewModelNotifier extends Notifier { + Conversation? get value => state; + + @override + Conversation? build() => null; + + void update(Conversation conversation) { + state = conversation; + } + + void selectByContact(Contact contact) { + // 1. Go to the chat page + ref.read(homePageTabViewModel.notifier).state = HomePageTab.chat; + // 2. Check if the conversation already selected + final selectedConversation = state; + if (selectedConversation?.hasSameContact(contact) ?? false) { + return; + } + // 3. Check if the conversation already exists + final conversationsData = ref.read(conversationsDataViewModel); + final conversations = conversationsData.value; + if (conversations == null) { + return; + } + for (final conversation in conversations) { + if (conversation.hasSameContact(contact)) { + state = conversation; + return; + } + } + // 4. Create a new conversation + final newConversation = Conversation.from( + contact: contact, + // TODO: get history messages from local database + // and (optional) server. + messages: []); + ref + .read(conversationsDataViewModel.notifier) + .addConversation(newConversation); + state = newConversation; + } + + void replaceMessage(Int64 messageId, ChatMessage message) { + final messages = state!.messages; + final index = messages.indexWhere((e) => messageId == e.messageId); + if (index < 0) { + return; + } + messages[index] = message; + ref.notifyListeners(); + } + + void addMessage(ChatMessage message) { + state!.messages.add(message); + ref.notifyListeners(); + } + + void notifyListeners() { + ref.notifyListeners(); + } + + void removeMessage(Int64 messageId) { + state!.messages.removeWhere((element) => element.messageId == messageId); + } +} + +final selectedConversationViewModel = + NotifierProvider( + SelectedConversationViewModelNotifier.new); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/contact_profile_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/contact_profile_page.dart new file mode 100644 index 0000000000..ab32fbe315 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/contact_profile_page.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../domain/user/models/contact.dart'; +import '../../../../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../themes/sizes.dart'; +import '../../../components/index.dart'; +import '../chat_page/view_models/selected_conversation_view_model.dart'; +import 'request_notifications_page/request_notifications_page.dart'; +import 'view_models/selected_contact_view_model.dart'; + +class ContactProfilePage extends ConsumerStatefulWidget { + const ContactProfilePage({super.key}); + + @override + ConsumerState createState() => _ContactProfilePageState(); +} + +class _ContactProfilePageState extends ConsumerState { + @override + Widget build(BuildContext context) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + final selectedContact = ref.watch(selectedContactViewModel); + if (selectedContact == null) { + return const TWindowControlZone( + toggleMaximizeOnDoubleTap: true, child: TEmpty()); + } + if (selectedContact is SystemContact && + selectedContact.type == SystemContactType.requestNotification) { + return const RequestNotificationsPage(); + } + final intro = selectedContact.intro; + return Stack( + children: [ + _buildProfile(appLocalizations, selectedContact, intro), + const TWindowControlZone( + toggleMaximizeOnDoubleTap: true, + child: SizedBox( + height: Sizes.homePageHeaderHeight, + width: double.infinity, + ), + ), + ], + ); + } + + Padding _buildProfile(AppLocalizations appLocalizations, + Contact selectedContact, String intro) => + Padding( + padding: const EdgeInsets.symmetric(vertical: 120), + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: 300, + child: Column( + children: [ + Row( + spacing: 16, + children: [ + TAvatar( + id: selectedContact.id, + name: selectedContact.name, + image: selectedContact.image, + icon: selectedContact.icon, + size: TAvatarSize.large, + ), + Expanded( + child: Padding( + padding: Sizes.paddingV4, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + selectedContact.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + ), + // TODO: Add more details + if (selectedContact is UserContact) + Text( + '${appLocalizations.userId}: ${selectedContact.userId}') + else if (selectedContact is GroupContact) + Text( + '${appLocalizations.groupId}: ${selectedContact.groupId}') + ], + ), + ), + ) + ], + ), + if (intro.isNotBlank) ...[ + const SizedBox( + height: 16, + ), + Text(selectedContact.intro) + ], + const SizedBox( + height: 32, + ), + TTextButton( + text: appLocalizations.messages, + onTap: () { + ref + .read(selectedConversationViewModel.notifier) + .selectByContact(selectedContact); + }, + ) + ], + ), + ), + ), + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/contact_tile.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/contact_tile.dart new file mode 100644 index 0000000000..79ebdaa80a --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/contact_tile.dart @@ -0,0 +1,66 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../domain/user/models/contact.dart'; +import '../../../../themes/app_theme_extension.dart'; +import '../../../components/t_avatar/t_avatar.dart'; +import '../../../components/t_list_tile/t_list_tile.dart'; + +class ContactTile extends StatefulWidget { + const ContactTile( + {super.key, + required this.contact, + required this.nameTextSpans, + required this.isSearching, + required this.highlighted, + required this.selected, + required this.onTap}); + + final Contact contact; + final List nameTextSpans; + final bool isSearching; + final bool highlighted; + final bool selected; + final GestureTapCallback onTap; + + @override + State createState() => _ContactTileState(); +} + +class _ContactTileState extends State { + @override + Widget build(BuildContext context) { + final appThemeExtension = context.appThemeExtension; + final contact = widget.contact; + return TListTile( + onTap: widget.onTap, + focused: widget.selected, + backgroundColor: widget.highlighted + ? appThemeExtension.tileBackgroundHighlightedColor + : appThemeExtension.tileBackgroundColor, + padding: + // use more right padding to reserve space for scrollbar + const EdgeInsets.only(left: 10, right: 14, top: 12, bottom: 12), + child: Row(mainAxisSize: MainAxisSize.min, spacing: 8, children: [ + TAvatar( + id: contact.id, + name: contact.name, + image: contact.image, + icon: contact.icon, + ), + Expanded( + child: Text.rich( + TextSpan( + children: widget.isSearching + ? widget.nameTextSpans + : [ + TextSpan( + text: contact.name, + ) + ]), + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + )) + ])); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/contacts_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/contacts_page.dart new file mode 100644 index 0000000000..e6517090ed --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/contacts_page.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../themes/index.dart'; +import '../../../components/t_divider/t_vertical_divider.dart'; +import '../../view_models/sub_navigation_rail_width_view_model.dart'; +import 'contact_profile_page.dart'; +import 'sub_navigation_rail.dart'; + +class ContactsPage extends ConsumerStatefulWidget { + const ContactsPage({super.key}); + + @override + ConsumerState createState() => _ContactsPageState(); +} + +class _ContactsPageState extends ConsumerState { + double _widthOnPointDown = 0; + + @override + Widget build(BuildContext context) { + final subNavigationRailWidth = ref.watch(subNavigationRailWidthViewModel); + final appThemeExtension = context.appThemeExtension; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Stack(children: [ + _buildSubNavigationRail(appThemeExtension, subNavigationRailWidth), + Positioned( + top: 0, + bottom: 0, + right: -Sizes.subNavigationRailDividerSize.padding.right, + child: TMovableVerticalDivider( + color: appThemeExtension.subNavigationRailDividerColor, + onMove: () { + _widthOnPointDown = subNavigationRailWidth; + }, + onMoved: (delta) { + ref + .read(subNavigationRailWidthViewModel.notifier) + .update(_widthOnPointDown + delta); + }, + ), + ), + ]), + _buildContactProfilePage(appThemeExtension), + ], + ); + } + + Widget _buildSubNavigationRail( + AppThemeExtension appThemeExtension, double subNavigationRailWidth) => + SizedBox( + width: subNavigationRailWidth, + child: const SubNavigationRail(), + ); + + Widget _buildContactProfilePage(AppThemeExtension appThemeExtension) => + Expanded( + child: ColoredBox( + color: appThemeExtension.homePageBackgroundColor, + child: const ContactProfilePage(), + ), + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/friend_requests_page/friend_requests_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/friend_requests_page/friend_requests_page.dart new file mode 100644 index 0000000000..5dce601954 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/friend_requests_page/friend_requests_page.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../../domain/common/models/request_status.dart'; +import '../../../../../../../domain/user/models/contact.dart'; +import '../../../../../../../domain/user/models/friend_request.dart'; +import '../../../../../../../domain/user/services/user_service.dart'; +import '../../../chat_page/view_models/selected_conversation_view_model.dart'; +import '../new_relationship_requests_page/new_relationship_requests_page.dart'; +import 'friend_requests_view_model.dart'; + +class FriendRequestsPage extends ConsumerStatefulWidget { + const FriendRequestsPage({super.key}); + + @override + ConsumerState createState() => _FriendRequestsPageState(); +} + +class _FriendRequestsPageState extends ConsumerState { + @override + Widget build(BuildContext context) { + final friendRequests = ref.watch(friendRequestsViewModel); + return NewRelationshipRequestsPage( + requests: friendRequests, + onRequestStatusChange: (request, requestStatus) async { + switch (requestStatus) { + case RequestStatus.accepted: + return _acceptFriendRequest(request as FriendRequest); + default: + return; + } + }, + onStartConversationTap: (value) { + _startConversation(value as FriendRequest); + }, + ); + } + + Future _acceptFriendRequest(FriendRequest request) async { + final notifier = ref.read(friendRequestsViewModel.notifier); + await ref.read(userServiceProvider)!.acceptFriendRequest(request.id); + notifier.replace(request, request.copyWith(status: RequestStatus.accepted)); + } + + void _startConversation(FriendRequest friendRequest) { + ref.read(selectedConversationViewModel.notifier).selectByContact( + UserContact( + userId: friendRequest.sender.userId, + name: friendRequest.sender.name)); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/friend_requests_page/friend_requests_view_model.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/friend_requests_page/friend_requests_view_model.dart new file mode 100644 index 0000000000..c3cc3fd8b8 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/friend_requests_page/friend_requests_view_model.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../../domain/common/fixtures/fixtures.dart'; +import '../../../../../../../domain/user/fixtures/friend_requests.dart'; +import '../../../../../../../domain/user/models/friend_request.dart'; +import '../../../../../../../infra/built_in_types/built_in_type_helpers.dart'; + +class FriendRequestsViewModelNotifier extends Notifier> { + @override + List build() => Fixtures.instance.friendRequests; + + void replace(FriendRequest oldRequest, FriendRequest newRequest) { + state.replace(oldRequest, newRequest); + ref.notifyListeners(); + } +} + +final friendRequestsViewModel = + NotifierProvider>( + FriendRequestsViewModelNotifier.new); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/group_membership_requests_page/group_membership_requests_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/group_membership_requests_page/group_membership_requests_page.dart new file mode 100644 index 0000000000..0d3d2761e1 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/group_membership_requests_page/group_membership_requests_page.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../../domain/common/models/request_status.dart'; +import '../../../../../../../domain/group/models/group_membership_request.dart'; +import '../../../../../../../domain/group/services/group_service.dart'; +import '../../../../../../../domain/user/models/contact.dart'; +import '../../../chat_page/view_models/selected_conversation_view_model.dart'; +import '../new_relationship_requests_page/new_relationship_requests_page.dart'; +import 'group_membership_requests_view_model.dart'; + +class GroupMembershipRequestsPage extends ConsumerStatefulWidget { + const GroupMembershipRequestsPage({super.key}); + + @override + ConsumerState createState() => + _GroupMembershipRequestsPageState(); +} + +class _GroupMembershipRequestsPageState + extends ConsumerState { + @override + Widget build(BuildContext context) { + final groupMembershipRequest = ref.watch(groupMembershipRequestsViewModel); + return NewRelationshipRequestsPage( + requests: groupMembershipRequest, + onRequestStatusChange: (request, requestStatus) async { + switch (requestStatus) { + case RequestStatus.accepted: + return _approveGroupMembershipRequest( + request as GroupMembershipRequest); + default: + return; + } + }, + onStartConversationTap: (value) { + _startConversation(value as GroupMembershipRequest); + }, + ); + } + + Future _approveGroupMembershipRequest( + GroupMembershipRequest request) async { + final notifier = ref.read(groupMembershipRequestsViewModel.notifier); + await ref + .read(groupServiceProvider)! + .approveGroupMembershipRequest(request.id); + notifier.replace(request, request.copyWith(status: RequestStatus.accepted)); + } + + void _startConversation(GroupMembershipRequest request) { + final group = request.group; + ref + .read(selectedConversationViewModel.notifier) + .selectByContact(GroupContact( + groupId: group.id, + name: group.name, + // TODO + members: [])); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/group_membership_requests_page/group_membership_requests_view_model.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/group_membership_requests_page/group_membership_requests_view_model.dart new file mode 100644 index 0000000000..1df8ebfa66 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/group_membership_requests_page/group_membership_requests_view_model.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../../domain/common/fixtures/fixtures.dart'; +import '../../../../../../../domain/group/fixtures/group_membership_requests.dart'; +import '../../../../../../../domain/group/models/group_membership_request.dart'; +import '../../../../../../../infra/built_in_types/built_in_type_helpers.dart'; + +class GroupMembershipRequestsViewModelNotifier + extends Notifier> { + @override + List build() => + Fixtures.instance.groupMembershipRequests; + + void replace( + GroupMembershipRequest oldRequest, GroupMembershipRequest newRequest) { + state.replace(oldRequest, newRequest); + ref.notifyListeners(); + } +} + +final groupMembershipRequestsViewModel = NotifierProvider< + GroupMembershipRequestsViewModelNotifier, + List>(GroupMembershipRequestsViewModelNotifier.new); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/new_relationship_requests_page/new_relationship_request_tile.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/new_relationship_requests_page/new_relationship_request_tile.dart new file mode 100644 index 0000000000..ba50e64f34 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/new_relationship_requests_page/new_relationship_request_tile.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../../domain/common/models/new_relationship_request.dart'; +import '../../../../../../../domain/common/models/request_status.dart'; +import '../../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../../../themes/index.dart'; + +import '../../../../../components/t_avatar/t_avatar.dart'; +import '../../../../../components/t_button/t_text_button.dart'; +import '../../../../../components/t_menu/t_context_menu.dart'; + +class NewRelationshipRequestTile extends ConsumerStatefulWidget { + const NewRelationshipRequestTile( + {Key? key, + required this.request, + required this.onAccept, + required this.onStartConversation}) + : super(key: key); + + final NewRelationshipRequest request; + final Future Function() onAccept; + final void Function() onStartConversation; + + @override + _NewRelationshipRequestTileState createState() => + _NewRelationshipRequestTileState(); +} + +class _NewRelationshipRequestTileState + extends ConsumerState { + bool _isHandling = false; + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final appThemeExtension = theme.appThemeExtension; + final appLocalizations = ref.watch(appLocalizationsViewModel); + final request = widget.request; + final sender = request.sender; + final message = request.message; + final status = request.status; + return Row( + children: [ + TAvatar( + id: sender.userId, + name: sender.name, + image: sender.image, + ), + Sizes.sizedBoxW16, + Expanded( + child: SelectionArea( + contextMenuBuilder: buildContextMenuForSelectableRegion, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sender.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + message, + style: appThemeExtension.descriptionTextStyle, + strutStyle: StrutStyle.fromTextStyle( + appThemeExtension.descriptionTextStyle, + forceStrutHeight: true), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + Sizes.sizedBoxW16, + // TODO: support decline + status == RequestStatus.accepted + ? TTextButton.outlined( + theme: theme, + containerWidth: 80, + containerPadding: Sizes.paddingV4H8, + text: appLocalizations.messages, + onTap: widget.onStartConversation, + ) + : TTextButton( + containerWidth: 80, + containerPadding: Sizes.paddingV4H8, + text: appLocalizations.accept, + isLoading: _isHandling, + onTap: () async { + _isHandling = true; + setState(() {}); + await widget.onAccept(); + _isHandling = false; + setState(() {}); + }, + ), + Sizes.sizedBoxW8, + ], + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/new_relationship_requests_page/new_relationship_requests_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/new_relationship_requests_page/new_relationship_requests_page.dart new file mode 100644 index 0000000000..326d7edf59 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/new_relationship_requests_page/new_relationship_requests_page.dart @@ -0,0 +1,112 @@ +import 'dart:collection'; + +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../../domain/common/models/new_relationship_request.dart'; +import '../../../../../../../domain/common/models/request_status.dart'; +import '../../../../../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../../../../l10n/app_localizations.dart'; +import '../../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../../../l10n/view_models/date_format_view_models.dart'; +import '../../../../../../themes/sizes.dart'; +import '../../../../../components/index.dart'; +import 'new_relationship_request_tile.dart'; + +class NewRelationshipRequestsPage extends ConsumerStatefulWidget { + const NewRelationshipRequestsPage( + {super.key, + required this.requests, + required this.onRequestStatusChange, + required this.onStartConversationTap}); + + final List requests; + final Future Function( + NewRelationshipRequest request, RequestStatus requestStatus) + onRequestStatusChange; + final ValueChanged onStartConversationTap; + + @override + ConsumerState createState() => _NewRelationshipRequestsPageState(); +} + +class _NewRelationshipRequestsPageState + extends ConsumerState { + @override + Widget build(BuildContext context) { + final requests = widget.requests + // Sort it to display the most recent first. + ..sort((a, b) => b.creationDate.compareTo(a.creationDate)); + final creationDateToRequests = requests.groupByAsLinkedHashMap((request) { + final creationDate = request.creationDate; + return DateTime(creationDate.year, creationDate.month, creationDate.day); + }); + final groupCount = creationDateToRequests.length; + final creationDateAndRequests = creationDateToRequests.entries.toList(); + final requestGroups = creationDateToRequests.values; + final groupIdToIndex = { + for (var i = 0; i < groupCount; i++) + requestGroups.elementAt(i).first.id: i + }; + return _buildFriendRequestGroups( + ref, creationDateAndRequests, groupIdToIndex); + } + + Widget _buildFriendRequestGroups( + WidgetRef ref, + List>> + creationDateAndRequests, + Map groupIdToIndex) { + final now = DateTime.now(); + final appLocalizations = ref.watch(appLocalizationsViewModel); + return ListView.separated( + itemCount: creationDateAndRequests.length, + // Prevent the scrollbar from overlapping children. + padding: const EdgeInsets.only(right: 24), + findChildIndexCallback: (key) => + groupIdToIndex[(key as ValueKey).value], + separatorBuilder: (context, index) => Sizes.sizedBoxH16, + itemBuilder: (context, index) { + final entry = creationDateAndRequests[index]; + return _buildRequestGroupOfSameDay( + entry.key, now, appLocalizations, ref, entry.value); + }, + ); + } + + Widget _buildRequestGroupOfSameDay( + DateTime creationDate, + DateTime now, + AppLocalizations appLocalizations, + WidgetRef ref, + List requests) => + Column( + key: ValueKey(requests.first.id), + children: [ + if (DateUtils.isSameDay(creationDate, now)) + Text(appLocalizations.today) + else if (creationDate.year == now.year) + Text(ref.watch(dateFormatViewModel_Md).format(creationDate)) + else + Text(ref.watch(dateFormatViewModel_yMd).format(creationDate)), + Sizes.sizedBoxH8, + const THorizontalDivider(), + Sizes.sizedBoxH12, + ...requests.indexed.expand((item) { + final (requestIndex, request) = item; + return [ + if (requestIndex > 0) const SizedBox(height: 16), + NewRelationshipRequestTile( + key: Key(request.id.toString()), + request: request, + onAccept: () async => widget.onRequestStatusChange( + request, RequestStatus.accepted), + onStartConversation: () => + widget.onStartConversationTap(request), + ) + ]; + }) + ], + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/request_notifications_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/request_notifications_page.dart new file mode 100644 index 0000000000..8d68fc0ac7 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/request_notifications_page/request_notifications_page.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../../themes/index.dart'; +import '../../../../components/index.dart'; +import 'friend_requests_page/friend_requests_page.dart'; +import 'group_membership_requests_page/group_membership_requests_page.dart'; + +class RequestNotificationsPage extends ConsumerStatefulWidget { + const RequestNotificationsPage({super.key}); + + @override + ConsumerState createState() => + _RequestNotificationsPageState(); +} + +class _RequestNotificationsPageState + extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + return Column(children: [ + Stack( + children: [ + const Positioned.fill( + child: TWindowControlZone( + toggleMaximizeOnDoubleTap: true, + )), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: Sizes.paddingV16, + child: TabBar( + isScrollable: true, + tabAlignment: TabAlignment.start, + padding: const EdgeInsets.symmetric(horizontal: 8), + dividerHeight: 0, + controller: _tabController, + tabs: [ + Tab( + text: appLocalizations.friendRequests, + height: 40, + ), + Tab( + text: appLocalizations.groupMembershipRequests, + height: 40, + ) + ], + ), + ), + ), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + bottom: 16, + left: 16, + // Used to avoid the scrollbar aligning to the right exactly. + right: 16), + child: TabBarView(controller: _tabController, children: [ + const FriendRequestsPage(), + const GroupMembershipRequestsPage(), + ]), + ), + ) + ]); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/sub_navigation_rail.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/sub_navigation_rail.dart new file mode 100644 index 0000000000..2aa12a226d --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/sub_navigation_rail.dart @@ -0,0 +1,570 @@ +import 'package:collection/collection.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../domain/user/models/contact.dart'; +import '../../../../../../domain/user/services/user_service.dart'; +import '../../../../../../infra/data/t_async_data.dart'; +import '../../../../../../infra/ui/text_utils.dart'; +import '../../../../../domain/user/models/relationship_group.dart'; +import '../../../../../infra/navigation/navigation_utils.dart'; +import '../../../../../infra/ui/scroll_utils.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../themes/app_theme_extension.dart'; +import '../../../../themes/sizes.dart'; +import '../../../components/index.dart'; +import 'contact_tile.dart'; +import 'view_models/contacts_view_model.dart'; +import 'view_models/relationship_groups_view_model.dart'; +import 'view_models/selected_contact_view_model.dart'; + +class SubNavigationRail extends ConsumerStatefulWidget { + const SubNavigationRail({super.key}); + + @override + ConsumerState createState() => _SubNavigationRailState(); +} + +class _SubNavigationRailState extends ConsumerState { + late AppLocalizations _appLocalizations; + late TextEditingController _searchBarTextEditingController; + late FocusNode _searchBarFocusNode; + late ScrollController _scrollControllerForNormal; + late ScrollController _scrollControllerForSearch; + + final Set<_RelationshipGroupContactKey> _relationshipGroupContactKeys = {}; + final Map _relationshipGroupIdToController = {}; + final Set<_ContactKey> _contactKeysForSearch = {}; + final Map _relationshipContactIndexToKey = + {}; + TAsyncData> _contactsData = const TAsyncData(); + TAsyncData> _relationshipGroupsData = + const TAsyncData(); + int? _highlightedContactTileItemIndex; + + final _SearchData _searchData = _SearchData(); + + @override + void initState() { + super.initState(); + _searchBarTextEditingController = TextEditingController(); + _searchBarFocusNode = FocusNode() + ..addListener( + _updateHighlightedContactTileItemIndexOnSearchBarFocusChanged); + _scrollControllerForNormal = ScrollController(); + _scrollControllerForSearch = ScrollController(); + ref + ..listenManual( + fireImmediately: true, + appLocalizationsViewModel, + (previous, next) { + _appLocalizations = next; + if (_contactsData.isInitialized) { + _contactsData = _contactsData.copyWith( + value: ref.read(userServiceProvider)!.getSystemContacts(next) + + _contactsData.value!); + if (_searchData.isSearching) { + _onSearchTextUpdated(_searchData.searchText); + } else { + setState(() {}); + } + } + }, + ) + ..listenManual( + fireImmediately: true, + contactsDataViewModel, + (previous, next) { + if (next.isInitialized) { + final appLocalizations = ref.read(appLocalizationsViewModel); + final newContacts = ref + .read(userServiceProvider)! + .getSystemContacts(appLocalizations) + + next.value!; + _contactsData = next.copyWith(value: newContacts); + } else { + _contactsData = next; + } + if (_searchData.isSearching) { + _onSearchTextUpdated(_searchData.searchText); + } else { + setState(() {}); + } + }, + ); + } + + @override + void dispose() { + _searchBarTextEditingController.dispose(); + _searchBarFocusNode.dispose(); + _scrollControllerForNormal.dispose(); + _scrollControllerForSearch.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final selectedContact = ref.watch(selectedContactViewModel); + _relationshipGroupsData = ref.watch(relationshipGroupsDataViewModel); + + var index = 0; + for (final group + in _relationshipGroupsData.value ?? []) { + for (final contact in group.contacts) { + _relationshipContactIndexToKey[index++] = _RelationshipGroupContactKey( + groupId: group.id, recordId: contact.recordId); + } + } + + final theme = context.theme; + final appThemeExtension = theme.appThemeExtension; + + return _buildView( + theme, appThemeExtension, _appLocalizations, selectedContact); + } + + void _selectContact(Contact contact) { + ref.read(selectedContactViewModel.notifier).state = contact; + } + + void _onSearchTextUpdated(String value) { + final newSearchText = value.toLowerCase().trim(); + final newIsSearching = newSearchText.isNotEmpty; + _highlightedContactTileItemIndex = null; + + _searchData.isSearching = newIsSearching; + if (newIsSearching) { + _searchData + ..isSearching = true + ..searchText = newSearchText + ..searchResults = (_contactsData.value ?? []) + .where((contact) => TextUtils.shouldHighlightText( + text: contact.name, searchText: newSearchText)) + .toList(); + } else { + _searchData.reset(); + } + setState(() {}); + } + + void _updateHighlightedContactTileItemIndexOnSearchBarFocusChanged() { + if (_searchBarFocusNode.hasFocus) { + final _selectedContact = ref.read(selectedContactViewModel); + final selectedConversationRecordId = _selectedContact?.recordId; + if (selectedConversationRecordId == null) { + _highlightedContactTileItemIndex = null; + setState(() {}); + } else if (_relationshipGroupsData.isInitialized) { + var i = 0; + var found = false; + for (final group in _relationshipGroupsData.value!) { + for (final contact in group.contacts) { + if (contact.recordId == selectedConversationRecordId) { + _highlightedContactTileItemIndex = i; + setState(() {}); + found = true; + break; + } + i++; + } + if (found) { + break; + } + } + } + } else { + _highlightedContactTileItemIndex = null; + setState(() {}); + } + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + final isSearching = _searchData.isSearching; + final total = isSearching + ? _searchData.searchResults.length + : _relationshipContactIndexToKey.length; + final (result, newIndex) = NavigationUtils.navigateByKeyEvent( + event, total, _highlightedContactTileItemIndex); + if (result == KeyEventResult.handled) { + if (_highlightedContactTileItemIndex != newIndex) { + _highlightedContactTileItemIndex = newIndex!; + setState(() {}); + if (isSearching) { + _scrollToByContactKey(newIndex, + _ContactKey(_searchData.searchResults[newIndex].recordId)); + } else { + _scrollToByRelationshipGroupContactKey( + _relationshipContactIndexToKey[newIndex]!); + } + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.enter) { + if (_highlightedContactTileItemIndex case final itemIndex?) { + if (_searchData.isSearching) { + _selectConversationWhenSearching( + _searchData.searchResults[itemIndex].recordId); + } else { + _selectConversationWhenNotSearching( + _relationshipContactIndexToKey[itemIndex]!.recordId); + } + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (_highlightedContactTileItemIndex case final itemIndex?) { + if (!_searchData.isSearching) { + final controller = _relationshipGroupIdToController[ + _relationshipContactIndexToKey[itemIndex]!.groupId]; + if (controller != null) { + controller.close?.call(); + } + } + } + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + + void _scrollToByContactKey(int itemIndex, _ContactKey key) { + SchedulerBinding.instance.addPostFrameCallback((_) { + final found = _contactKeysForSearch.lookup(key); + if (found == null) { + return; + } + final currentContext = found.currentContext; + if (currentContext == null || !currentContext.mounted) { + return; + } + ScrollUtils.ensureVisible( + controller: _scrollControllerForSearch, + viewportDimension: + _scrollControllerForSearch.position.viewportDimension, + itemOffset: Sizes.conversationTileHeight * itemIndex, + itemHeight: Sizes.conversationTileHeight); + }); + } + + void _scrollToByRelationshipGroupContactKey( + _RelationshipGroupContactKey relationshipGroupContactKey) { + var isOpening = false; + final controller = + _relationshipGroupIdToController[relationshipGroupContactKey.groupId]; + assert(controller != null); + if (controller != null) { + isOpening = controller.open?.call(onOpenCompleted: () { + _scrollToByRelationshipGroupContactKey0( + relationshipGroupContactKey); + }) ?? + false; + } + if (!isOpening) { + SchedulerBinding.instance.addPostFrameCallback((_) { + _scrollToByRelationshipGroupContactKey0(relationshipGroupContactKey); + }); + } + } + + void _scrollToByRelationshipGroupContactKey0( + _RelationshipGroupContactKey relationshipGroupContactKey) { + final found = + _relationshipGroupContactKeys.lookup(relationshipGroupContactKey); + if (found == null) { + return; + } + final currentContext = found.currentContext; + if (currentContext == null || !currentContext.mounted) { + return; + } + final renderBox = currentContext.findRenderObject() as RenderBox; + ScrollUtils.ensureRenderBoxVisible(renderBox: renderBox); + } + + void _selectConversationWhenNotSearching(String recordId) { + final contact = (_contactsData.value ?? []).firstWhereOrNull( + (element) => element.recordId == recordId, + ); + if (contact == null) { + return; + } + _selectContact(contact); + } + + void _selectConversationWhenSearching(String recordId) { + final contact = (_contactsData.value ?? []).firstWhereOrNull( + (element) => element.recordId == recordId, + ); + if (contact == null) { + return; + } + _selectContact(contact); + _searchBarTextEditingController.clear(); + _onSearchTextUpdated(''); + } +} + +extension _SubNavigationRailStateView on _SubNavigationRailState { + Widget _buildView(ThemeData theme, AppThemeExtension appThemeExtension, + AppLocalizations appLocalizations, Contact? selectedContact) => + Focus( + onKeyEvent: _onKeyEvent, + child: Padding( + padding: EdgeInsets.only( + right: Sizes.subNavigationRailDividerSize.thickness), + child: ColoredBox( + color: appThemeExtension.tileBackgroundColor, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSearchBar(appThemeExtension, appLocalizations), + if (_contactsData.isLoading || + _relationshipGroupsData.isLoading) + _buildLoadingIndicator(appThemeExtension), + Expanded( + child: _searchData.isSearching + ? _buildContactTiles(appThemeExtension, + appLocalizations, selectedContact) + // TODO: use builder + : ListView( + cacheExtent: 128, + controller: _scrollControllerForNormal, + children: _buildRelationshipGroups(appLocalizations, + selectedContact, _relationshipGroupsData), + ), + ), + ], + )), + ), + ); + + Widget _buildLoadingIndicator(AppThemeExtension appThemeExtension) => + SizedBox( + height: 40, + child: ColoredBox( + color: appThemeExtension + .subNavigationRailLoadingIndicatorBackgroundColor, + child: const Center( + child: CupertinoActivityIndicator(radius: 8), + ), + ), + ); + + Widget _buildSearchBar(AppThemeExtension appThemeExtension, + AppLocalizations appLocalizations) => + SizedBox( + height: Sizes.homePageHeaderHeight, + child: ColoredBox( + color: appThemeExtension.subNavigationRailSearchBarBackgroundColor, + child: Padding( + padding: Sizes.subNavigationRailPadding, + child: Center( + child: TSearchBar( + textEditingController: _searchBarTextEditingController, + focusNode: _searchBarFocusNode, + hintText: appLocalizations.search, + onChanged: _onSearchTextUpdated, + ), + ), + ), + ), + ); + + Widget _buildContactTiles(AppThemeExtension appThemeExtension, + AppLocalizations appLocalizations, Contact? selectedContact) { + final selectedContactRecordId = selectedContact?.recordId; + final matchedContacts = + _searchData.searchResults.expand<_StyledContact>((contact) { + final nameTextSpans = TextUtils.highlightSearchText( + text: contact.name, + searchText: _searchData.searchText, + searchTextStyle: + appThemeExtension.conversationTileHighlightedTextStyle); + if (nameTextSpans.length == 1) { + return []; + } + return [_StyledContact(contact: contact, nameTextSpans: nameTextSpans)]; + }).toList(); + + final itemCount = matchedContacts.length; + final contactRecordIdToIndex = { + for (var i = 0; i < itemCount; i++) matchedContacts[i].contact.recordId: i + }; + _contactKeysForSearch.clear(); + return ListView.builder( + controller: _scrollControllerForSearch, + addAutomaticKeepAlives: false, + itemCount: itemCount, + findChildIndexCallback: (key) => + contactRecordIdToIndex[(key as _ContactKey).value], + prototypeItem: ContactTile( + contact: UserContact(userId: Int64.MIN_VALUE, name: ''), + nameTextSpans: [], + isSearching: true, + highlighted: false, + selected: false, + onTap: () {}, + ), + itemBuilder: (BuildContext context, int index) { + final _StyledContact(:contact, :nameTextSpans) = + matchedContacts[index]; + final contactKey = _ContactKey(contact.recordId); + if (!_contactKeysForSearch.add(contactKey)) { + throw AssertionError('Duplicate contact key: $contactKey'); + } + return ContactTile( + key: contactKey, + contact: contact, + nameTextSpans: nameTextSpans, + isSearching: true, + highlighted: _highlightedContactTileItemIndex == index, + selected: contact.recordId == selectedContactRecordId, + onTap: () { + _selectContact(contact); + }, + ); + }); + } + + List _buildRelationshipGroups( + AppLocalizations appLocalizations, + Contact? selectedContact, + TAsyncData> relationshipGroupsData) { + final selectedContactRecordId = selectedContact?.recordId; + // final widgets = ref + // .read(userServiceProvider)! + // .getSystemContacts(appLocalizations) + // .map((contact) => ContactTile( + // contact: contact, + // nameTextSpans: [], + // isSearching: false, + // selected: contact.recordId == selectedContactRecordId, + // onTap: () { + // selectContact(contact); + // }, + // )) + // .toList(); + + _relationshipGroupContactKeys.clear(); + _relationshipGroupIdToController.clear(); + final widgets = []; + // TODO: Load contacts lazily if there are more than 20 contacts. + final groups = relationshipGroupsData.value ?? []; + var index = 0; + for (final group in groups) { + final entry = _buildRelationshipGroup( + index, group.id, group.name, group.contacts, selectedContactRecordId); + index = entry.$2; + widgets.add(entry.$1); + } + + return widgets; + } + + (Widget, int) _buildRelationshipGroup(int startIndex, Int64 groupId, + String name, List contacts, String? selectedContactRecordId) { + assert(_relationshipGroupIdToController[groupId] == null); + final controller = TAccordionController(); + _relationshipGroupIdToController[groupId] = controller; + final widget = TAccordion( + controller: controller, + titleChild: Row( + spacing: 4, + children: [ + Text(name), + Text('(${contacts.length})'), + ], + ), + // TODO: Use ListView for better performance + contentChild: Column( + children: contacts.map((contact) { + final relationshipGroupContactKey = _RelationshipGroupContactKey( + groupId: groupId, recordId: contact.recordId); + if (!_relationshipGroupContactKeys.add(relationshipGroupContactKey)) { + throw AssertionError( + 'Duplicate relationship group contact key: $relationshipGroupContactKey'); + } + return ContactTile( + key: relationshipGroupContactKey, + contact: contact, + highlighted: _highlightedContactTileItemIndex == startIndex++, + selected: contact.recordId == selectedContactRecordId, + onTap: () { + _selectContact(contact); + }, + nameTextSpans: [], + isSearching: false, + ); + }).toList(), + ), + ); + return (widget, startIndex); + } +} + +class _SearchData { + bool isSearching = false; + String searchText = ''; + List searchResults = []; + + void reset() { + isSearching = false; + searchText = ''; + searchResults = []; + } +} + +class _StyledContact { + const _StyledContact({required this.contact, required this.nameTextSpans}); + + final Contact contact; + final List nameTextSpans; +} + +class _RelationshipGroupContactKey extends GlobalObjectKey { + const _RelationshipGroupContactKey({ + required this.groupId, + required this.recordId, + }) : super('$groupId:$recordId'); + + final Int64 groupId; + final String recordId; + + @override + int get hashCode => Object.hash(groupId, recordId); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is _RelationshipGroupContactKey && + other.groupId == groupId && + other.recordId == recordId; + } + + @override + String toString() => '_RelationshipGroupContactKey($value)'; +} + +class _ContactKey extends GlobalObjectKey { + const _ContactKey(String recordId) : super(recordId); + + @override + int get hashCode => value.hashCode; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is _ContactKey && other.value == value; + } + + @override + String toString() => '_ContactKey($value)'; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/view_models/contacts_view_model.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/view_models/contacts_view_model.dart new file mode 100644 index 0000000000..d8ed9b22b2 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/view_models/contacts_view_model.dart @@ -0,0 +1,16 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../domain/user/models/index.dart'; +import '../../../../../../infra/data/t_async_data.dart'; + +final contactsDataViewModel = + StateProvider>>((ref) => const TAsyncData()); + +final userContactsViewModel = StateProvider>((ref) => + ref + .watch(contactsDataViewModel) + .value + ?.where((element) => element is UserContact) + .cast() + .toList() ?? + []); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/view_models/relationship_groups_view_model.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/view_models/relationship_groups_view_model.dart new file mode 100644 index 0000000000..bb28e6127b --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/view_models/relationship_groups_view_model.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../domain/user/models/index.dart'; +import '../../../../../../infra/data/t_async_data.dart'; + +final relationshipGroupsDataViewModel = + StateProvider>>( + (ref) => const TAsyncData()); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/view_models/selected_contact_view_model.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/view_models/selected_contact_view_model.dart new file mode 100644 index 0000000000..d4255be123 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/contacts_page/view_models/selected_contact_view_model.dart @@ -0,0 +1,5 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../domain/user/models/contact.dart'; + +final selectedContactViewModel = StateProvider((ref) => null); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/create_group_page/create_group_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/create_group_page/create_group_page.dart new file mode 100644 index 0000000000..e57a4a05c4 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/create_group_page/create_group_page.dart @@ -0,0 +1,290 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../../domain/group/services/group_service.dart'; +import '../../../../../domain/user/models/contact.dart'; +import '../../../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../../../infra/ui/text_utils.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../themes/index.dart'; +import '../../../components/index.dart'; +import '../../app.dart'; +import '../contacts_page/view_models/contacts_view_model.dart'; + +class CreateGroupPage extends ConsumerStatefulWidget { + const CreateGroupPage({super.key, required this.selectedContactIds}); + + final Set selectedContactIds; + + @override + ConsumerState createState() => _CreateGroupPageState(); +} + +class _CreateGroupPageState extends ConsumerState { + Set _selectedUserContactIds = {}; + final List _selectedUserContacts = []; + + bool _isCreating = false; + String _searchText = ''; + + @override + void initState() { + super.initState(); + _selectedUserContactIds = widget.selectedContactIds.toSet(); + } + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final appThemeExtension = theme.appThemeExtension; + final appLocalizations = ref.watch(appLocalizationsViewModel); + final userContacts = ref.watch(userContactsViewModel); + return Padding( + padding: Sizes.paddingV8H16, + child: Column(children: [ + Text(appLocalizations.createGroup), + Sizes.sizedBoxH16, + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: Sizes.borderRadiusCircular4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: Column( + children: [ + IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: Padding( + padding: Sizes.paddingH8, + child: TSearchBar( + hintText: appLocalizations.search, + onChanged: _updateSearchText, + ), + )), + const TVerticalDivider(), + Expanded( + child: Text( + textAlign: TextAlign.center, + appLocalizations.selectedContacts, + ), + ), + ], + ), + ), + Expanded( + child: Row( + children: [ + Expanded( + child: _buildContacts( + appThemeExtension, userContacts)), + const TVerticalDivider(), + Expanded( + child: _buildSelectedContacts( + appThemeExtension, appLocalizations)) + ], + ), + ), + ], + ), + ), + ), + ), + Sizes.sizedBoxH12, + _buildActions(context, theme, appLocalizations) + ]), + ); + } + + void close() { + popTopIfNameMatched(createGroupDialogRouteName); + } + + Future _createGroup() async { + _isCreating = true; + setState(() {}); + await ref.read(groupServiceProvider)!.createGroup(); + _isCreating = false; + close(); + } + + void _addSelectedContact(UserContact userContact) { + if (_selectedUserContactIds.add(userContact.userId)) { + _selectedUserContacts.add(userContact); + setState(() {}); + } + } + + void _removeSelectedContact(UserContact userContact) { + if (_selectedUserContactIds.remove(userContact.userId)) { + _selectedUserContacts.remove(userContact); + setState(() {}); + } + } + + void _updateSearchText(String value) { + _searchText = value.toLowerCase().trim(); + setState(() {}); + } + + ListView _buildContacts( + AppThemeExtension appThemeExtension, List userContacts) { + final isSearching = _searchText.isNotBlank; + final matchedUserContacts = isSearching + ? userContacts.expand<(UserContact, List)>((contact) { + final spans = TextUtils.highlightSearchText( + text: contact.name, + searchText: _searchText, + searchTextStyle: appThemeExtension.highlightTextStyle); + if (spans.length == 1) { + return []; + } + return [(contact, spans)]; + }).toList() + : userContacts + .map((contact) => ( + contact, + [ + TextSpan(text: contact.name), + ] + )) + .toList(); + + final itemCount = matchedUserContacts.length; + final matchedContactRecordIdToIndex = { + for (var i = 0; i < itemCount; i++) matchedUserContacts[i].$1.recordId: i + }; + return ListView.builder( + itemCount: matchedUserContacts.length, + findChildIndexCallback: (key) => + matchedContactRecordIdToIndex[(key as ValueKey).value], + itemBuilder: (BuildContext context, int index) { + final (userContact, spans) = matchedUserContacts[index]; + return TListTile( + key: Key(userContact.recordId), + backgroundColor: Colors.white, + padding: Sizes.paddingH8, + height: 40, + child: Row( + spacing: 8, + children: [ + TSimpleCheckbox( + value: _selectedUserContactIds.contains(userContact.userId), + onChanged: (value) { + if (value) { + _addSelectedContact(userContact); + } else { + _removeSelectedContact(userContact); + } + }), + TAvatar( + id: userContact.id, + name: userContact.name, + size: TAvatarSize.small, + ), + Flexible( + child: Text.rich( + TextSpan( + children: spans, + ), + // userContact.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }); + } + + Widget _buildSelectedContacts( + AppThemeExtension appThemeExtension, AppLocalizations appLocalizations) { + final itemCount = _selectedUserContacts.length; + final selectedUserContactIdToIndex = { + for (var i = 0; i < itemCount; i++) _selectedUserContacts[i].userId: i + }; + return ListView.builder( + itemCount: itemCount, + findChildIndexCallback: (key) => + selectedUserContactIdToIndex[(key as ValueKey).value], + itemBuilder: (BuildContext context, int index) { + final userContact = _selectedUserContacts[index]; + return TListTile( + key: ValueKey(userContact.id), + backgroundColor: Colors.white, + padding: Sizes.paddingH8, + height: 40, + child: Row( + children: [ + TAvatar( + id: userContact.id, + name: userContact.name, + size: TAvatarSize.small, + ), + Sizes.sizedBoxW8, + Expanded( + child: Text( + userContact.name, + // userContact.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + TIconButton( + iconData: Symbols.close_rounded, + iconColor: appThemeExtension.descriptionTextStyle.color!, + iconSize: 16, + addContainer: false, + onTap: () { + _removeSelectedContact(userContact); + }, + ) + ], + ), + ); + }); + } + + Widget _buildActions(BuildContext context, ThemeData theme, + AppLocalizations appLocalizations) => + Row( + mainAxisAlignment: MainAxisAlignment.end, + spacing: 16, + children: [ + TTextButton.outlined( + theme: theme, + text: appLocalizations.cancel, + containerPadding: Sizes.paddingV4H8, + containerWidth: 64, + onTap: close, + ), + TTextButton( + isLoading: _isCreating, + disabled: _selectedUserContactIds.length <= 1, + text: appLocalizations.create, + containerPadding: Sizes.paddingV4H8, + containerWidth: 64, + onTap: _createGroup, + ) + ], + ); +} + +const createGroupDialogRouteName = '/create-group-dialog'; + +Future showCreateGroupDialog({ + required BuildContext context, + Set selectedUserIds = const {}, +}) => + showSimpleTDialog( + routeName: createGroupDialogRouteName, + context: context, + child: CreateGroupPage( + selectedContactIds: selectedUserIds, + )); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/files_page/file_icon/file_icon.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/files_page/file_icon/file_icon.dart new file mode 100644 index 0000000000..372668b2df --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/files_page/file_icon/file_icon.dart @@ -0,0 +1,81 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +import '../../../../../../infra/ui/color_extensions.dart'; + +const _defaultColor = Colors.grey; +final _fileFormatToImage = {}; + +class FileIcon extends StatelessWidget { + FileIcon({super.key, required String fileFormat}) + : fileFormat = fileFormat.toUpperCase(); + + final String fileFormat; + + @override + Widget build(BuildContext context) => + RawImage(image: _getFileImage(fileFormat)); +} + +ui.Image _getFileImage(String fileFormat) => + _fileFormatToImage.putIfAbsent(fileFormat, () { + final recorder = ui.PictureRecorder(); + _FileIconPainter(fileFormat).paint(Canvas(recorder), const Size(21, 28)); + final picture = recorder.endRecording(); + return picture.toImageSync(21, 28); + }); + +class _FileIconPainter extends CustomPainter { + const _FileIconPainter(this.fileFormat) : color = _defaultColor; + + final String fileFormat; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final length = size.width * 0.35; + final left = size.width - length; + canvas + // Clip the corner. + ..clipPath(Path() + ..lineTo(left, 0) + ..lineTo(size.width, length) + ..lineTo(size.width, size.height) + ..lineTo(0, size.height) + ..close()) + // Clip a rounded rectangle. + ..drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(0, 0, size.width, size.height), + const Radius.circular(4)), + Paint()..color = color) + // Draw the corner. + ..drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(left, -length, length * 2, length * 2), + const Radius.circular(2)), + Paint()..color = color.darken(0.2)); + + final paragraphBuilder = ui.ParagraphBuilder( + ui.ParagraphStyle( + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + ), + ) + ..pushStyle(ui.TextStyle( + color: Colors.white, + fontSize: 10, + )) + ..addText(fileFormat); + final paragraph = paragraphBuilder.build() + ..layout(ui.ParagraphConstraints(width: size.width)); + canvas.drawParagraph( + paragraph, + Offset((size.width - paragraph.width) / 2, + (size.height - paragraph.height) / 2)); + } + + @override + bool shouldRepaint(_FileIconPainter oldDelegate) => + fileFormat != oldDelegate.fileFormat; +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/files_page/files_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/files_page/files_page.dart new file mode 100644 index 0000000000..c1bab6c86f --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/files_page/files_page.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../../domain/file/models/file_info.dart'; +import '../../../../../domain/file/services/file_service.dart'; +import '../../../../../infra/data/t_async_data.dart'; +import '../../../../../infra/units/file_size_extensions.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../l10n/view_models/date_format_view_models.dart'; +import '../../../../themes/app_theme_extension.dart'; +import '../../../../themes/sizes.dart'; +import '../../../components/index.dart'; +import 'file_icon/file_icon.dart'; + +class FilesPage extends ConsumerStatefulWidget { + const FilesPage({super.key}); + + @override + ConsumerState createState() => _FilesPageState(); +} + +class _FilesPageState extends ConsumerState { + TAsyncData> _fileInfosData = const TAsyncData(); + + @override + void initState() { + super.initState(); + TAsyncData.fromFuture(() => ref.read(fileServiceProvider)!.queryFiles()) + .forEach( + (data) { + if (mounted) { + _fileInfosData = data; + setState(() {}); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + final appThemeExtension = context.appThemeExtension; + final appLocalizations = ref.watch(appLocalizationsViewModel); + return _buildView(appThemeExtension, appLocalizations); + } + + void _downloadOrOpen() { + // TODO: download + download animation + use real file path + // OpenFile.open(''); + } +} + +extension _FilesPageView on _FilesPageState { + Widget _buildView( + AppThemeExtension appThemeExtension, AppLocalizations appLocalizations) { + // use "add_Hm()" instead of "add_jm()" + // to make the string short and concise + final dateFormat = ref.watch(dateFormatViewModel_yMdHm); + + return Column( + children: [ + _buildQueryFilters(appThemeExtension, appLocalizations), + _buildTable( + appThemeExtension, appLocalizations, dateFormat, _fileInfosData), + ], + ); + } + + Widget _buildQueryFilters( + AppThemeExtension appThemeExtension, AppLocalizations appLocalizations) { + final now = DateTime.now(); + return ColoredBox( + color: appThemeExtension.homePageBackgroundColor, + child: ConstrainedBox( + constraints: + const BoxConstraints.tightFor(height: Sizes.homePageHeaderHeight), + child: Stack(children: [ + const TWindowControlZone(toggleMaximizeOnDoubleTap: true), + Center( + child: Padding( + padding: EdgeInsets.symmetric( + // Use the same padding as the navigation rail + // so they are aligned. + horizontal: Sizes.subNavigationRailPadding.left, + vertical: 16), + child: Row( + spacing: Sizes.subNavigationRailPadding.left, + children: [ + SizedBox( + width: 200, + child: TSearchBar(hintText: appLocalizations.fileName), + ), + TDateRangePicker( + firstDate: DateTime(now.year - 3), + lastDate: now, + initialDateRange: DateTimeRange( + start: now.subtract(const Duration(days: 7)), + end: now, + ), + ), + ], + ), + ), + ), + ]), + ), + ); + } + + Widget _buildTable( + AppThemeExtension appThemeExtension, + AppLocalizations appLocalizations, + DateFormat dateFormat, + TAsyncData> fileInfosData) { + if (!fileInfosData.isInitialized || fileInfosData.isLoading) { + // TODO: polish UI + return const Expanded(child: Center(child: CircularProgressIndicator())); + } + final fileInfos = fileInfosData.value!; + return Expanded( + child: TTable( + header: TTableRow(cells: [ + const TTableDataCell(widget: Icon(Symbols.insert_drive_file_rounded)), + _buildHeaderCell(appThemeExtension, appLocalizations.fileName), + _buildHeaderCell(appThemeExtension, appLocalizations.fileUploadDate), + _buildHeaderCell(appThemeExtension, appLocalizations.fileUploader), + _buildHeaderCell(appThemeExtension, appLocalizations.fileSize), + _buildHeaderCell(appThemeExtension, appLocalizations.progress), + ]), + rows: fileInfos + .map((e) => TTableRow( + onTap: _downloadOrOpen, + cells: [ + TTableDataCell(widget: FileIcon(fileFormat: e.type)), + _buildTextDataCell(appThemeExtension, e.name, false), + _buildTextDataCell(appThemeExtension, + dateFormat.format(e.uploadDate), true), + _buildTextDataCell(appThemeExtension, e.uploader, true), + _buildTextDataCell(appThemeExtension, + e.size.toHumanReadableFileSize(), true), + const TTableDataCell( + widget: Padding( + padding: EdgeInsets.only(right: 16), + child: RepaintBoundary(child: LinearProgressIndicator()), + )), + ], + )) + .toList(), + columnOptions: [ + const TTableColumnOption(width: 0.05), + const TTableColumnOption(width: 0.30), + const TTableColumnOption(width: 0.15), + const TTableColumnOption(width: 0.225), + const TTableColumnOption(width: 0.10), + const TTableColumnOption(width: 0.175), + ], + ), + ); + } + + TTableDataCell _buildHeaderCell( + AppThemeExtension appThemeExtension, String title) => + TTableDataCell( + widget: Text(title, + style: appThemeExtension.fileTableTitleTextStyle, + overflow: TextOverflow.ellipsis)); + + TTableDataCell _buildTextDataCell( + AppThemeExtension appThemeExtension, String text, bool isSecondary) => + TTableDataCell( + widget: Text( + text, + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: isSecondary ? appThemeExtension.fileTableCellTextStyle : null, + )); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page.dart new file mode 100644 index 0000000000..7030244399 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +import '../../components/t_layout/t_responsive_layout.dart'; +import 'home_page_landscape.dart'; +import 'home_page_portrait.dart'; + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) => const TResponsiveLayout( + portraitLayoutContent: HomePagePortrait(), + landscapeLayoutContent: HomePageLandscape(), + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page_action.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page_action.dart new file mode 100644 index 0000000000..d8d936d9ef --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page_action.dart @@ -0,0 +1,63 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../domain/user/models/index.dart'; +import '../../../../domain/user/models/user_setting.dart'; +import 'about_page/about_page.dart'; +import 'home_page_tab.dart'; +import 'settings_page/settings_page.dart'; +import 'shared_view_models/home_page_tab_view_model.dart'; + +enum HomePageAction { + showChatPage( + userSetting: UserSetting.shortcutShowChatPage, + defaultShortcutActivator: + SingleActivator(LogicalKeyboardKey.digit1, alt: true)), + showContactsPage( + userSetting: UserSetting.shortcutShowContactsPage, + defaultShortcutActivator: + SingleActivator(LogicalKeyboardKey.digit2, alt: true)), + showFilesPage( + userSetting: UserSetting.shortcutShowFilesPage, + defaultShortcutActivator: + SingleActivator(LogicalKeyboardKey.digit3, alt: true)), + showSettingsDialog( + userSetting: UserSetting.shortcutShowSettingsDialog, + defaultShortcutActivator: + SingleActivator(LogicalKeyboardKey.digit4, alt: true)), + showAboutDialog( + userSetting: UserSetting.shortcutShowAboutDialog, + defaultShortcutActivator: + SingleActivator(LogicalKeyboardKey.digit5, alt: true)); + + const HomePageAction({ + required this.userSetting, + required this.defaultShortcutActivator, + }); + + final UserSetting userSetting; + final ShortcutActivator defaultShortcutActivator; +} + +extension HomePageActionExtension on HomePageAction { + void trigger({required BuildContext context, required WidgetRef ref}) { + switch (this) { + case HomePageAction.showChatPage: + ref.read(homePageTabViewModel.notifier).state = HomePageTab.chat; + break; + case HomePageAction.showContactsPage: + ref.read(homePageTabViewModel.notifier).state = HomePageTab.contacts; + break; + case HomePageAction.showFilesPage: + ref.read(homePageTabViewModel.notifier).state = HomePageTab.files; + break; + case HomePageAction.showSettingsDialog: + showSettingsDialog(context); + break; + case HomePageAction.showAboutDialog: + showAppAboutDialog(context); + break; + } + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page_landscape.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page_landscape.dart new file mode 100644 index 0000000000..17ba560f04 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page_landscape.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +import '../../../../domain/user/view_models/user_settings_view_model.dart'; +import '../../../../infra/app/app_config.dart'; +import '../../../../infra/env/env_vars.dart'; +import '../../../../infra/github/github_client.dart'; +import '../../../../infra/logging/logger.dart'; +import '../../../../infra/rust/api/system.dart'; +import '../../../../infra/task/task_utils.dart'; +import '../../../../infra/units/file_size_extensions.dart'; +import '../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../themes/index.dart'; +import '../../components/index.dart'; +import 'action_to_shortcut_view_model.dart'; +import 'chat_page/chat_page.dart'; +import 'contacts_page/contacts_page.dart'; +import 'files_page/files_page.dart'; +import 'home_page_action.dart'; +import 'home_page_tab.dart'; +import 'main_navigation_rail/main_navigation_rail.dart'; +import 'shared_view_models/home_page_tab_view_model.dart'; + +const _taskIdCheckDiskSpace = 'checkDiskSpace'; +const _taskIdCheckForUpdates = 'checkForUpdates'; +final _diskWarningThreshold = BigInt.from(100).MB; + +class HomePageLandscape extends ConsumerStatefulWidget { + const HomePageLandscape({Key? key}) : super(key: key); + + @override + ConsumerState createState() => _HomePageLandscapeState(); +} + +class _HomePageLandscapeState extends ConsumerState { + @override + Widget build(BuildContext context) { + final tab = ref.watch(homePageTabViewModel); + final actionToShortcut = ref.watch(actionToShortcutViewModel); + final bindings = {}; + for (final action in HomePageAction.values) { + final shortcut = actionToShortcut[action]?.shortcutActivator; + if (shortcut != null) { + bindings[shortcut] = () { + hideAllPopups(); + action.trigger(context: context, ref: ref); + }; + } + } + final child = CallbackShortcuts( + bindings: bindings, + child: FocusScope( + debugLabel: 'HomePageLandscape', + autofocus: true, + child: Stack( + children: [ + Row( + children: [ + const SizedBox( + width: Sizes.mainNavigationRailWidth, + child: MainNavigationRail(), + ), + Expanded( + child: TLazyIndexedStack( + index: switch (tab) { + HomePageTab.chat => 0, + HomePageTab.contacts => 1, + HomePageTab.files => 2, + }, + children: [ + const RepaintBoundary(child: ChatPage()), + const RepaintBoundary(child: ContactsPage()), + const RepaintBoundary(child: FilesPage()), + ], + )) + ], + ), + const TTitleBar(), + ], + ), + ), + ); + if (EnvVars.showFocusTracker) { + // TODO + // return TFocusTracker( + // child: child, + // ); + } + return child; + } + + @override + void initState() { + super.initState(); + TaskUtils.addPeriodicTask( + id: _taskIdCheckForUpdates, + duration: const Duration(hours: 1), + callback: () async { + final checkForUpdates = + ref.read(userSettingsViewModel)?.checkForUpdatesAutomatically ?? + false; + if (!checkForUpdates) { + return true; + } + try { + final file = await GithubUtils.downloadLatestApp(); + // TODO: pop up a dialog to notify user. + } catch (e, s) { + logger.warn('Failed to download latest application', e, s); + } + return true; + }); + TaskUtils.addPeriodicTask( + id: _taskIdCheckDiskSpace, + duration: const Duration(minutes: 1), + callback: () async { + final diskSpaceInfos = getDiskSpaceInfos(); + final diskSpace = diskSpaceInfos.firstWhereOrNull( + (info) => p.isWithin(info.path, AppConfig.appDir)); + if (diskSpace != null && diskSpace.available < _diskWarningThreshold) { + final appLocalizations = ref.read(appLocalizationsViewModel); + unawaited(showAlertDialog( + context, + title: appLocalizations.lowDiskSpace, + content: appLocalizations.lowDiskSpacePrompt(100), + onTapConfirm: () { + Navigator.of(context).pop(); + }, + )); + } + return true; + }, + ); + } + + @override + void dispose() { + TaskUtils.removeTask(_taskIdCheckForUpdates); + TaskUtils.removeTask(_taskIdCheckDiskSpace); + super.dispose(); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page_portrait.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page_portrait.dart new file mode 100644 index 0000000000..efbe359346 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page_portrait.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class HomePagePortrait extends StatelessWidget { + const HomePagePortrait({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) => const Scaffold( + body: Placeholder(), + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page_tab.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page_tab.dart new file mode 100644 index 0000000000..126ac0c9a9 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/home_page_tab.dart @@ -0,0 +1,6 @@ +enum HomePageTab { + chat, + contacts, + files, + // settings +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/main_navigation_rail/main_navigation_rail.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/main_navigation_rail/main_navigation_rail.dart new file mode 100644 index 0000000000..dc0d24b25d --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/main_navigation_rail/main_navigation_rail.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../domain/user/services/user_service.dart'; +import '../../../../../domain/user/view_models/logged_in_user_info_view_model.dart'; +import '../../../../themes/app_theme_extension.dart'; +import '../../../../themes/sizes.dart'; +import '../../../components/index.dart'; +import '../shared_components/user_profile_popup.dart'; +import 'tabs.dart'; + +class MainNavigationRail extends ConsumerWidget { + const MainNavigationRail({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final loggedInUser = ref.watch(loggedInUserViewModel)!; + return ColoredBox( + color: context.appThemeExtension.mainNavigationRailBackgroundColor, + child: Padding( + padding: const EdgeInsets.only(top: 32, bottom: 16), + child: Column( + spacing: 24, + children: [ + UserProfilePopup( + user: loggedInUser, + imageEditable: true, + presence: loggedInUser.presence, + presencePopupOffset: Offset( + (Sizes.mainNavigationRailWidth - + TAvatarSize.medium.containerSize) / + 2 + + Sizes.mainNavigationRailElementPopupOffsetX, + 0), + onPresenceSelected: (value) { + ref.read(userServiceProvider)!.updatePresence(value); + }, + ), + const Expanded(child: Tabs()) + ], + ), + ), + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/main_navigation_rail/tabs.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/main_navigation_rail/tabs.dart new file mode 100644 index 0000000000..4772f6b13e --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/main_navigation_rail/tabs.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../../domain/user/view_models/logged_in_user_info_view_model.dart'; +import '../../../../../infra/keyboard/shortcut_extensions.dart'; +import '../../../../l10n/view_models/app_localizations_view_model.dart'; + +import '../../../../themes/index.dart'; +import '../../../components/index.dart'; +import '../about_page/about_page.dart'; +import '../action_to_shortcut_view_model.dart'; +import '../home_page_action.dart'; +import '../home_page_tab.dart'; +import '../settings_page/settings_page.dart'; +import '../shared_view_models/home_page_tab_view_model.dart'; + +class Tabs extends ConsumerStatefulWidget { + const Tabs({super.key}); + + @override + ConsumerState createState() => _TabsState(); +} + +class _TabsState extends ConsumerState { + @override + Widget build(BuildContext context) { + final theme = context.theme; + final appThemeExtension = theme.appThemeExtension; + + final homePageTab = ref.watch(homePageTabViewModel); + final isChatTab = homePageTab == HomePageTab.chat; + final isContactsTab = homePageTab == HomePageTab.contacts; + final isFilesTab = homePageTab == HomePageTab.files; + final appLocalizations = ref.watch(appLocalizationsViewModel); + final actionToShortcut = ref.watch(actionToShortcutViewModel); + + final shortcutShowChatPage = + actionToShortcut[HomePageAction.showChatPage]?.shortcutActivator; + final shortcutShowContactsPage = + actionToShortcut[HomePageAction.showContactsPage]?.shortcutActivator; + final shortcutShowFilesPage = + actionToShortcut[HomePageAction.showFilesPage]?.shortcutActivator; + return Column(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Column(spacing: 4, children: [ + TIconButton( + iconData: Symbols.chat_rounded, + iconFill: isChatTab, + iconSize: 26, + iconWeight: isChatTab ? 400 : 300, + tooltip: shortcutShowChatPage == null + ? appLocalizations.chats + : '${appLocalizations.chats} (${shortcutShowChatPage.description})', + onTap: () => + ref.read(homePageTabViewModel.notifier).state = HomePageTab.chat, + iconColor: isChatTab + ? theme.primaryColor + : appThemeExtension.mainNavigationRailIconColor, + ), + TIconButton( + iconData: Symbols.person_rounded, + iconFill: isContactsTab, + iconSize: 26, + iconWeight: isContactsTab ? 400 : 300, + tooltip: shortcutShowContactsPage == null + ? appLocalizations.contacts + : '${appLocalizations.contacts} (${shortcutShowContactsPage.description})', + onTap: () => ref.read(homePageTabViewModel.notifier).state = + HomePageTab.contacts, + iconColor: isContactsTab + ? theme.primaryColor + : appThemeExtension.mainNavigationRailIconColor, + ), + TIconButton( + iconData: Symbols.description_rounded, + iconFill: isFilesTab, + iconSize: 26, + iconWeight: isFilesTab ? 400 : 300, + tooltip: shortcutShowFilesPage == null + ? appLocalizations.files + : '${appLocalizations.files} (${shortcutShowFilesPage.description})', + onTap: () => + ref.read(homePageTabViewModel.notifier).state = HomePageTab.files, + iconColor: isFilesTab + ? theme.primaryColor + : appThemeExtension.mainNavigationRailIconColor, + ), + ]), + TMenuPopup( + offset: const Offset( + Sizes.mainNavigationRailWidth / 2 + + Sizes.mainNavigationRailElementPopupOffsetX, + 0), + padding: Sizes.paddingV8H16, + targetAnchor: Alignment.topCenter, + followerAnchor: Alignment.bottomLeft, + constrainFollowerWithTargetWidth: false, + entries: [ + TMenuEntry( + value: 0, + label: appLocalizations.settings, + onSelected: () { + showSettingsDialog(context); + }, + ), + TMenuEntry( + value: 1, + label: appLocalizations.about, + onSelected: () { + showAppAboutDialog(context); + }, + ), + TMenuEntry.separator, + TMenuEntry( + value: 2, + label: appLocalizations.logOut, + onSelected: () { + // TODO: Reset all states related to the logged-in user. + ref.read(loggedInUserViewModel.notifier).state = null; + }, + ), + ], + anchor: TIconButton( + iconData: Symbols.menu_rounded, + iconSize: 26, + iconWeight: 300, + tooltip: appLocalizations.settings, + iconColor: appThemeExtension.mainNavigationRailIconColor, + ), + ), + ]); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/new_relationship_page/friend_request_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/new_relationship_page/friend_request_page.dart new file mode 100644 index 0000000000..c8a084d2cd --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/new_relationship_page/friend_request_page.dart @@ -0,0 +1,160 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../domain/user/models/contact.dart'; +import '../../../../../domain/user/services/user_service.dart'; +import '../../../../../infra/built_in_types/built_in_type_helpers.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../themes/app_theme_extension.dart'; +import '../../../../themes/sizes.dart'; +import '../../../components/index.dart'; +import '../../app.dart'; + +class FriendRequestPage extends ConsumerStatefulWidget { + const FriendRequestPage(this.contact, {super.key}); + + final Contact contact; + + @override + ConsumerState createState() => _FriendRequestPageState(); +} + +class _FriendRequestPageState extends ConsumerState { + late TextEditingController _messageEditingController; + bool _isSending = false; + + @override + void initState() { + super.initState(); + _messageEditingController = TextEditingController(); + } + + @override + void dispose() { + _messageEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final appThemeExtension = theme.appThemeExtension; + final appLocalizations = ref.watch(appLocalizationsViewModel); + return _buildPage(theme, appThemeExtension, appLocalizations); + } + + Widget _buildPage(ThemeData theme, AppThemeExtension appThemeExtension, + AppLocalizations appLocalizations) { + final contact = widget.contact; + return SizedBox( + width: Sizes.friendRequestDialogWidth, + height: Sizes.friendRequestDialogHeight, + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: Sizes.paddingV16H16, + child: Column(children: [ + Text(appLocalizations.addContact), + Sizes.sizedBoxH16, + Row( + spacing: 8, + children: [ + TAvatar( + id: contact.id, + name: contact.name, + image: contact.image), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + contact.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (contact.intro.isNotBlank) + Text( + contact.intro, + style: appThemeExtension.descriptionTextStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + ], + ), + ) + ], + ), + Sizes.sizedBoxH8, + Align( + alignment: Alignment.centerLeft, + child: Text(appLocalizations.message), + ), + Sizes.sizedBoxH8, + Expanded( + child: TTextField( + autofocus: true, + expands: true, + textEditingController: _messageEditingController, + )), + Sizes.sizedBoxH12, + Row( + mainAxisAlignment: MainAxisAlignment.end, + spacing: 16, + children: [ + TTextButton.outlined( + theme: theme, + text: appLocalizations.cancel, + containerPadding: Sizes.paddingV4H8, + containerWidth: 64, + onTap: _close, + ), + TTextButton( + isLoading: _isSending, + text: appLocalizations.send, + containerPadding: Sizes.paddingV4H8, + containerWidth: 64, + onTap: () { + _sendFriendRequest( + (contact is UserContact) + ? contact.userId + : (contact as GroupContact).groupId, + _messageEditingController.text); + }, + ) + ], + ) + ]), + ), + ), + const TTitleBar( + displayCloseOnly: true, + popOnCloseTapped: true, + ) + ], + ), + ); + } + + Future _sendFriendRequest(Int64 userId, String content) async { + _isSending = true; + setState(() {}); + await ref.read(userServiceProvider)!.sendFriendRequest(userId, content); + _isSending = false; + _close(); + } + + void _close() { + popTopIfNameMatched(friendRequestDialogRouteName); + } +} + +const friendRequestDialogRouteName = '/friend-request-dialog'; + +Future showFriendRequestDialog(BuildContext context, Contact contact) => + showCustomTDialog( + routeName: friendRequestDialogRouteName, + context: context, + child: FriendRequestPage(contact)); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/new_relationship_page/new_relationship_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/new_relationship_page/new_relationship_page.dart new file mode 100644 index 0000000000..afa19dc913 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/new_relationship_page/new_relationship_page.dart @@ -0,0 +1,264 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../../domain/group/services/group_service.dart'; +import '../../../../../domain/user/models/index.dart'; +import '../../../../../domain/user/services/user_service.dart'; +import '../../../../../infra/data/t_async_data.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../themes/sizes.dart'; +import '../../../components/index.dart'; +import 'friend_request_page.dart'; +import 'relationship_info_tile.dart'; + +const safeAreaPaddingHorizontal = 24.0; + +Future showNewRelationshipDialog( + BuildContext context, bool showAddContactPage) => + showCustomTDialog( + routeName: '/new-relationship-dialog', + context: context, + child: NewRelationshipPage(showAddContactPage: showAddContactPage)); + +class NewRelationshipPage extends ConsumerStatefulWidget { + const NewRelationshipPage({super.key, required this.showAddContactPage}); + + final bool showAddContactPage; + + @override + ConsumerState createState() => + _NewRelationshipPageState(); +} + +class _NewRelationshipPageState extends ConsumerState + with SingleTickerProviderStateMixin { + late TextEditingController _searchTextController; + late TabController _tabController; + + TAsyncData> _userContacts = const TAsyncData(); + TAsyncData> _groupContacts = const TAsyncData(); + String _userContactSearchText = ''; + String _groupContactSearchText = ''; + + late _SearchType _searchType; + + @override + void initState() { + super.initState(); + _searchTextController = TextEditingController(); + _tabController = TabController(length: 2, vsync: this) + ..addListener( + () { + if (_tabController.index == 0) { + _searchType = _SearchType.user; + _searchTextController.text = _userContactSearchText; + } else { + _searchType = _SearchType.group; + _searchTextController.text = _groupContactSearchText; + } + setState(() {}); + }, + ); + if (widget.showAddContactPage) { + _searchType = _SearchType.user; + _tabController.index = 0; + } else { + _searchType = _SearchType.group; + _tabController.index = 1; + } + } + + @override + void dispose() { + _searchTextController.dispose(); + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + return _buildView(appLocalizations); + } + + Future _search(_SearchType searchType, String value, + ValueChanged>> contactsDataListener) async { + final num = Int64.tryParseInt(value); + if (num == null) { + contactsDataListener(const TAsyncData(value: [])); + return; + } + contactsDataListener(const TAsyncData(isLoading: true)); + switch (searchType) { + case _SearchType.user: + final searchResult = + await ref.read(userServiceProvider)!.searchUserContacts(num, value); + contactsDataListener(TAsyncData(value: searchResult as List)); + break; + case _SearchType.group: + final searchResult = await ref + .read(groupServiceProvider)! + .searchGroupContacts(num, value); + contactsDataListener(TAsyncData(value: searchResult as List)); + break; + } + if (!mounted) { + return; + } + setState(() {}); + } + + Future _searchUser(String value) => _search( + _SearchType.user, + value, + (value) { + _userContacts = value; + setState(() {}); + }, + ); + + Future _searchGroup(String value) => _search( + _SearchType.group, + value, + (value) { + _groupContacts = value; + setState(() {}); + }, + ); + + void _openFriendRequestDialog(Contact contact) { + showFriendRequestDialog(context, contact); + } + + void _openGroupJoinRequestDialog(Contact contact) { + // TODO + showFriendRequestDialog(context, contact); + } + + void _updateSearchText(String value) { + if (_searchType == _SearchType.user) { + _userContactSearchText = value; + } else { + _groupContactSearchText = value; + } + } +} + +extension _NewRelationshipPageView on _NewRelationshipPageState { + Widget _buildView(AppLocalizations appLocalizations) => SizedBox( + width: Sizes.dialogWidthMedium, + height: Sizes.dialogHeightMedium, + child: Stack( + children: [ + Positioned.fill( + child: _buildPage(appLocalizations), + ), + const TTitleBar( + displayCloseOnly: true, + popOnCloseTapped: true, + ) + ], + ), + ); + + Column _buildPage(AppLocalizations appLocalizations) => Column(children: [ + Sizes.sizedBoxH16, + TabBar( + isScrollable: true, + tabAlignment: TabAlignment.start, + padding: const EdgeInsets.symmetric(horizontal: 8), + dividerHeight: 0, + controller: _tabController, + tabs: [ + Tab( + text: appLocalizations.addContact, + height: 40, + ), + Tab( + text: appLocalizations.joinGroup, + height: 40, + ) + ], + ), + Sizes.sizedBoxH16, + Padding( + padding: + const EdgeInsets.symmetric(horizontal: safeAreaPaddingHorizontal), + child: TSearchBar( + textEditingController: _searchTextController, + hintText: appLocalizations.search, + autofocus: true, + keepFocusOnSubmit: true, + onChanged: _updateSearchText, + onSubmitted: (value) { + if (_searchType == _SearchType.user) { + _searchUser(value); + } else { + _searchGroup(value); + } + _updateSearchText(value); + }, + ), + ), + Sizes.sizedBoxH16, + Expanded( + child: TabBarView(controller: _tabController, children: [ + _buildSearchResultView(false, _userContacts), + _buildSearchResultView(true, _groupContacts), + ]), + ), + ]); + + Widget _buildSearchResultView( + bool isGroupContact, TAsyncData> contactsData) { + if (contactsData.isLoading) { + return const Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + color: Colors.blue, + ), + ), + ); + } + final contacts = contactsData.value ?? []; + final contactCount = contacts.length; + final idToIndex = { + for (var i = 0; i < contactCount; i++) contacts[i].recordId: i + }; + return contacts.isEmpty + ? contactsData.isInitialized + ? const TEmptyResult( + icon: Symbols.person_rounded, + ) + : const TEmpty() + : ListView.builder( + itemCount: contactCount, + findChildIndexCallback: (key) => + idToIndex[(key as ValueKey).value], + itemBuilder: (context, index) { + final contact = contacts[index]; + return RelationshipInfoTile( + key: ValueKey(contact.recordId), + isGroup: isGroupContact, + contact: contact, + onTap: () { + if (isGroupContact) { + _openGroupJoinRequestDialog(contact); + } else { + _openFriendRequestDialog(contact); + } + }, + ); + }); + } +} + +enum _SearchType { + user, + group, +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/new_relationship_page/relationship_info_tile.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/new_relationship_page/relationship_info_tile.dart new file mode 100644 index 0000000000..3e84b6e0d3 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/new_relationship_page/relationship_info_tile.dart @@ -0,0 +1,44 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../domain/user/models/contact.dart'; +import '../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../themes/index.dart'; +import '../../../components/index.dart'; +import 'new_relationship_page.dart'; + +class RelationshipInfoTile extends ConsumerWidget { + const RelationshipInfoTile( + {super.key, + required this.isGroup, + required this.contact, + required this.onTap}); + + final bool isGroup; + final Contact contact; + final GestureTapCallback onTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + return TListTile( + padding: const EdgeInsets.symmetric( + vertical: 12, horizontal: safeAreaPaddingHorizontal), + child: Row(mainAxisSize: MainAxisSize.min, spacing: 12, children: [ + TAvatar(id: contact.id, name: contact.name, image: contact.image), + Expanded( + child: Text( + contact.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + )), + TTextButton( + text: isGroup + ? appLocalizations.joinGroup + : appLocalizations.addContact, + containerPadding: Sizes.paddingV4H8, + onTap: onTap), + ])); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/settings_page/setting_form_field_groups.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/settings_page/setting_form_field_groups.dart new file mode 100644 index 0000000000..7210e7046f --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/settings_page/setting_form_field_groups.dart @@ -0,0 +1,49 @@ +import 'package:flutter/cupertino.dart'; + +import '../../../../l10n/app_localizations.dart'; + +enum SettingFormFieldGroup { + launchAndExit, + shortcuts, + status, + notifications, + appearance, + update, + // TODO: network proxy +} + +class SettingFormFieldGroupContext { + const SettingFormFieldGroupContext( + {required this.key, required this.getTitle}); + + final GlobalKey key; + final String Function(AppLocalizations appLocalizations) getTitle; +} + +final formFieldGroupToContext = + { + SettingFormFieldGroup.launchAndExit: SettingFormFieldGroupContext( + key: GlobalKey(debugLabel: SettingFormFieldGroup.launchAndExit.name), + getTitle: (appLocalizations) => appLocalizations.launchAndExit, + ), + SettingFormFieldGroup.shortcuts: SettingFormFieldGroupContext( + key: GlobalKey(debugLabel: SettingFormFieldGroup.shortcuts.name), + getTitle: (appLocalizations) => appLocalizations.shortcuts, + ), + SettingFormFieldGroup.status: SettingFormFieldGroupContext( + key: GlobalKey(debugLabel: SettingFormFieldGroup.status.name), + getTitle: (appLocalizations) => appLocalizations.status, + ), + SettingFormFieldGroup.notifications: SettingFormFieldGroupContext( + key: GlobalKey(debugLabel: SettingFormFieldGroup.notifications.name), + getTitle: (appLocalizations) => appLocalizations.notifications, + ), + SettingFormFieldGroup.appearance: SettingFormFieldGroupContext( + key: GlobalKey(debugLabel: SettingFormFieldGroup.appearance.name), + getTitle: (appLocalizations) => appLocalizations.appearance, + ), + SettingFormFieldGroup.update: SettingFormFieldGroupContext( + key: GlobalKey(debugLabel: SettingFormFieldGroup.update.name), + getTitle: (appLocalizations) => appLocalizations.update, + ), +}; diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/settings_page/settings_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/settings_page/settings_page.dart new file mode 100644 index 0000000000..0253a34ed3 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/settings_page/settings_page.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import '../../../../themes/sizes.dart'; +import '../../../components/index.dart'; +import 'setting_form_field_groups.dart'; +import 'settings_pane.dart'; +import 'sub_navigation_rail.dart'; + +/// UI design: We don't put all settings separated by groups in one view because: +/// 1. It seems messy. +/// 2. (James Chen) my mouse wheel always break in just few months after I bought them, +/// which make me avoiding designing scrollable UI. +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + @override + Widget build(BuildContext context) => SizedBox( + width: Sizes.dialogWidthMedium, + height: Sizes.dialogHeightMedium, + child: Row( + children: [ + SubNavigationRail( + onTabSelected: (index, tab) => _selectTab(tab), + ), + Expanded( + child: Column( + children: [ + const Align( + alignment: Alignment.topRight, + child: TTitleBar( + displayCloseOnly: true, + popOnCloseTapped: true, + usePositioned: false, + ), + ), + Expanded(child: SettingsPane( + onSettingFormFieldGroupScrolled: (index) { + _selectTabWithoutScroll(); + }, + )), + ], + ), + ), + ], + ), + ); + + void _selectTab(TTab tab) { + final fieldGroupContext = + formFieldGroupToContext[tab.id as SettingFormFieldGroup] + ?.key + .currentContext; + if (fieldGroupContext != null) { + Scrollable.ensureVisible(fieldGroupContext, + duration: const Duration(milliseconds: 100), + curve: Curves.fastOutSlowIn); + } + setState(() {}); + } + + void _selectTabWithoutScroll() { + setState(() {}); + } +} + +Future showSettingsDialog(BuildContext context) => showCustomTDialog( + routeName: '/settings-dialog', + context: context, + child: const SettingsPage()); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/settings_page/settings_pane.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/settings_page/settings_pane.dart new file mode 100644 index 0000000000..32820c580e --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/settings_page/settings_pane.dart @@ -0,0 +1,460 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../domain/user/models/index.dart'; +import '../../../../../domain/user/models/setting_action_on_close.dart'; +import '../../../../../domain/user/models/setting_locale.dart'; +import '../../../../../domain/user/repositories/user_setting_repository.dart'; +import '../../../../../domain/user/view_models/logged_in_user_info_view_model.dart'; +import '../../../../../domain/user/view_models/user_settings_view_model.dart'; +import '../../../../../infra/app/app_config.dart'; +import '../../../../../infra/autostart/autostart_manager.dart'; +import '../../../../../infra/keyboard/shortcut_extensions.dart'; +import '../../../../../infra/shortcut/shortcut.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../l10n/view_models/system_locale_info_view_model.dart'; +import '../../../../themes/app_theme_extension.dart'; + +import '../../../components/index.dart'; +import '../action_to_shortcut_view_model.dart'; +import '../home_page_action.dart'; +import 'setting_form_field_groups.dart'; + +class SettingsPane extends ConsumerStatefulWidget { + const SettingsPane( + {super.key, required this.onSettingFormFieldGroupScrolled}); + + final void Function(int index) onSettingFormFieldGroupScrolled; + + @override + ConsumerState createState() => _SettingsPaneState(); +} + +class _SettingsPaneState extends ConsumerState { + late AppLocalizations _appLocalizations; + late Map _actionToShortcut; + + late bool _useSystemLocale; + + final GlobalKey _scrollViewKey = GlobalKey(); + final AutostartManager _autostartManager = AutostartManager.create( + appName: AppConfig.packageInfo.appName, + appPath: Platform.resolvedExecutable, + args: []); + + late UserSettings _userSettings; + + @override + Widget build(BuildContext context) { + _appLocalizations = ref.watch(appLocalizationsViewModel); + _actionToShortcut = ref.watch(actionToShortcutViewModel); + final localInfo = ref.watch(localeInfoViewModel); + _useSystemLocale = localInfo.isSystemLocale; + _userSettings = ref.watch(userSettingsViewModel)!; + + return _SettingsPaneView(this); + } + + Future _updateActionOnClose(SettingActionOnClose value) async { + final userSettingsController = ref.read(userSettingsViewModel.notifier); + await userSettingRepository.upsert(ref.read(loggedInUserViewModel)!.userId, + UserSetting.actionOnClose, value); + userSettingsController.state!.actionOnClose = value; + userSettingsViewModelRef.notifyListeners(); + } + + Future _updateCheckForUpdatesAutomatically(bool value) async { + await userSettingRepository.upsert(ref.read(loggedInUserViewModel)!.userId, + UserSetting.checkForUpdatesAutomatically, value); + ref + .read(userSettingsViewModel.notifier) + .state! + .checkForUpdatesAutomatically = value; + userSettingsViewModelRef.notifyListeners(); + } + + Future _updateLaunchOnStartup(bool value) async { + final userSettingsController = ref.read(userSettingsViewModel.notifier); + try { + if (value) { + await _autostartManager.enable(); + } else { + await _autostartManager.disable(); + } + } catch (e) { + unawaited(TToast.showToast( + context, _appLocalizations.failedToUpdateSettings(e.toString()))); + } + userSettingsController.state!.launchOnStartup = value; + userSettingsViewModelRef.notifyListeners(); + } + + Future _updateLocale(SettingLocale value) async { + final Locale locale; + if (value == SettingLocale.system) { + locale = ref.read(localeInfoViewModel.notifier).useSystemLocale().locale; + await userSettingRepository.delete( + ref.read(loggedInUserViewModel)!.userId, + UserSetting.locale, + ); + ref.read(userSettingsViewModel.notifier).state!.locale = null; + } else { + final newLocale = ref + .read(localeInfoViewModel.notifier) + .updateLocaleIfSupported(value.name) + ?.locale; + assert(newLocale != null, 'Unsupported locale: ${value.name}'); + locale = newLocale!; + await userSettingRepository.upsert( + ref.read(loggedInUserViewModel)!.userId, UserSetting.locale, locale); + ref.read(userSettingsViewModel.notifier).state!.locale = locale; + } + userSettingsViewModelRef.notifyListeners(); + } + + Future _updateNewMessageNotification(bool value) async { + await userSettingRepository.upsert(ref.read(loggedInUserViewModel)!.userId, + UserSetting.newMessageNotification, value); + ref.read(userSettingsViewModel.notifier).state!.newMessageNotification = + value; + userSettingsViewModelRef.notifyListeners(); + } + + Future _updateShortcut( + {bool notify = true, + bool resetConflictedShortcuts = true, + required HomePageAction action, + ShortcutActivator? shortcutActivator}) async { + final userSetting = action.userSetting; + final userId = ref.read(loggedInUserViewModel)!.userId; + final userSettingsController = ref.read(userSettingsViewModel.notifier); + if (shortcutActivator == null) { + await userSettingRepository.upsert( + userId, + userSetting, + null, + ); + } else { + if (resetConflictedShortcuts) { + for (final homePageAction in HomePageAction.values) { + if (action != homePageAction && + (_actionToShortcut[homePageAction]! + .shortcutActivator + ?.hasSameKeys(shortcutActivator) ?? + false)) { + await _updateShortcut(notify: false, action: homePageAction); + } + } + } + await userSettingRepository.upsert( + userId, userSetting, shortcutActivator); + } + final userSettings = userSettingsController.state!; + switch (action) { + case HomePageAction.showChatPage: + userSettings.shortcutShowChatPage = Shortcut(shortcutActivator, true); + break; + case HomePageAction.showContactsPage: + userSettings.shortcutShowContactsPage = + Shortcut(shortcutActivator, true); + break; + case HomePageAction.showFilesPage: + userSettings.shortcutShowFilesPage = Shortcut(shortcutActivator, true); + break; + case HomePageAction.showSettingsDialog: + userSettings.shortcutShowSettingsDialog = + Shortcut(shortcutActivator, true); + break; + case HomePageAction.showAboutDialog: + userSettings.shortcutShowAboutDialog = + Shortcut(shortcutActivator, true); + break; + } + if (notify) { + userSettingsViewModelRef.notifyListeners(); + } + } + + Future _updateThemeMode(ThemeMode value) async { + final userSettingsController = ref.read(userSettingsViewModel.notifier); + await userSettingRepository.upsert( + ref.read(loggedInUserViewModel)!.userId, UserSetting.theme, value); + userSettingsController.state!.theme = value; + userSettingsViewModelRef.notifyListeners(); + } + + bool _hasAnyShortcutChanged() { + for (final homePageAction in HomePageAction.values) { + final hasSameKeys = _actionToShortcut[homePageAction] + ?.shortcutActivator + ?.hasSameKeys(homePageAction.defaultShortcutActivator) ?? + false; + if (!hasSameKeys) { + return true; + } + } + return false; + } + + Future _resetShortcuts() async { + for (final homePageAction in HomePageAction.values) { + await _updateShortcut( + notify: false, + resetConflictedShortcuts: false, + action: homePageAction, + shortcutActivator: homePageAction.defaultShortcutActivator); + } + userSettingsViewModelRef.notifyListeners(); + } +} + +class _SettingsPaneView extends StatelessWidget { + const _SettingsPaneView(this._settingsPaneController); + + final _SettingsPaneState _settingsPaneController; + + @override + Widget build(BuildContext context) => SingleChildScrollView( + key: _settingsPaneController._scrollViewKey, + child: Padding( + padding: const EdgeInsets.only(bottom: 16, left: 16, right: 16), + child: TForm( + formData: TFormData(groups: _buildFormGroups(context)), + ), + ), + ); + + List _buildFormGroups(BuildContext context) { + final appLocalizations = _settingsPaneController._appLocalizations; + return [ + for (final entry in formFieldGroupToContext.entries) + switch (entry.key) { + SettingFormFieldGroup.launchAndExit => + _buildLaunchAndExitFieldGroup(entry.value, appLocalizations), + SettingFormFieldGroup.shortcuts => _buildShortcutsFieldGroup( + entry.value, + appLocalizations, + _settingsPaneController._actionToShortcut, + context), + SettingFormFieldGroup.status => + _buildStatusFieldGroup(entry.value, appLocalizations), + SettingFormFieldGroup.notifications => + _buildNotificationsFieldGroup(entry.value, appLocalizations), + SettingFormFieldGroup.appearance => + _buildAppearanceFieldGroup(entry.value, appLocalizations), + SettingFormFieldGroup.update => + _buildUpdateFieldGroup(entry.value, appLocalizations), + } + ]; + } + + TFormFieldGroup _buildLaunchAndExitFieldGroup( + SettingFormFieldGroupContext context, + AppLocalizations appLocalizations) => + TFormFieldGroup( + title: context.getTitle(appLocalizations), + titleKey: context.key, + fields: [ + TFormFieldCheckbox( + label: appLocalizations.launchOnStartup, + onChanged: _settingsPaneController._updateLaunchOnStartup, + value: _settingsPaneController._userSettings.launchOnStartup ?? + false, + ), + TFormFieldRadioGroup( + label: appLocalizations.actionOnClose, + groupValue: _settingsPaneController._userSettings.actionOnClose ?? + SettingActionOnClose.minimizeToTray, + onChanged: _settingsPaneController._updateActionOnClose, + radios: [ + TFormFieldRadio( + value: SettingActionOnClose.minimizeToTray, + label: appLocalizations.minimizeToTray, + ), + TFormFieldRadio( + value: SettingActionOnClose.exit, + label: appLocalizations.exit, + ) + ], + ) + ]); + + TFormFieldGroup _buildShortcutsFieldGroup( + SettingFormFieldGroupContext formFieldGroupContext, + AppLocalizations appLocalizations, + Map actionToShortcut, + BuildContext context) { + final appThemeExtension = context.appThemeExtension; + return TFormFieldGroup( + title: formFieldGroupContext.getTitle(appLocalizations), + titleKey: formFieldGroupContext.key, + titleSuffix: _settingsPaneController._hasAnyShortcutChanged() + ? TTextButton( + text: appLocalizations.reset, + addContainer: false, + textStyle: appThemeExtension.linkTextStyle, + textStyleHovered: appThemeExtension.linkHoveredTextStyle, + onTap: _settingsPaneController._resetShortcuts, + ) + : null, + fields: [ + TFormFieldShortcutTextField( + label: '${appLocalizations.goToChatPage}:', + initialKeys: actionToShortcut[HomePageAction.showChatPage]! + .shortcutActivator + ?.keyList, + onShortcutChanged: (List keys) { + _settingsPaneController._updateShortcut( + action: HomePageAction.showChatPage, + shortcutActivator: keys.isEmpty + ? null + : LogicalKeySet.fromSet(keys.toSet())); + }), + TFormFieldShortcutTextField( + label: '${appLocalizations.goToContactsPage}:', + initialKeys: actionToShortcut[HomePageAction.showContactsPage]! + .shortcutActivator + ?.keyList, + onShortcutChanged: (List keys) => + _settingsPaneController._updateShortcut( + action: HomePageAction.showContactsPage, + shortcutActivator: keys.isEmpty + ? null + : LogicalKeySet.fromSet(keys.toSet()))), + TFormFieldShortcutTextField( + label: '${appLocalizations.goToFilesPage}:', + initialKeys: actionToShortcut[HomePageAction.showFilesPage]! + .shortcutActivator + ?.keyList, + onShortcutChanged: (List keys) => + _settingsPaneController._updateShortcut( + action: HomePageAction.showFilesPage, + shortcutActivator: keys.isEmpty + ? null + : LogicalKeySet.fromSet(keys.toSet()))), + TFormFieldShortcutTextField( + label: '${appLocalizations.openSettingsDialog}:', + initialKeys: actionToShortcut[HomePageAction.showSettingsDialog]! + .shortcutActivator + ?.keyList, + onShortcutChanged: (List keys) => + _settingsPaneController._updateShortcut( + action: HomePageAction.showSettingsDialog, + shortcutActivator: keys.isEmpty + ? null + : LogicalKeySet.fromSet(keys.toSet()))), + TFormFieldShortcutTextField( + label: '${appLocalizations.openAboutDialog}:', + initialKeys: actionToShortcut[HomePageAction.showAboutDialog]! + .shortcutActivator + ?.keyList, + onShortcutChanged: (List keys) => + _settingsPaneController._updateShortcut( + action: HomePageAction.showAboutDialog, + shortcutActivator: keys.isEmpty + ? null + : LogicalKeySet.fromSet(keys.toSet()))), + ]); + } + + TFormFieldGroup _buildStatusFieldGroup(SettingFormFieldGroupContext context, + AppLocalizations appLocalizations) => + TFormFieldGroup( + title: context.getTitle(appLocalizations), + titleKey: context.key, + fields: [ + TFormFieldCheckbox( + // TODO + label: appLocalizations.newMessageNotification, + value: _settingsPaneController + ._userSettings.newMessageNotification ?? + false, + onChanged: _settingsPaneController._updateNewMessageNotification, + ) + ]); + + TFormFieldGroup _buildNotificationsFieldGroup( + SettingFormFieldGroupContext context, + AppLocalizations appLocalizations) => + TFormFieldGroup( + title: context.getTitle(appLocalizations), + titleKey: context.key, + fields: [ + TFormFieldCheckbox( + label: appLocalizations.newMessageNotification, + value: _settingsPaneController + ._userSettings.newMessageNotification ?? + false, + onChanged: _settingsPaneController._updateNewMessageNotification, + ) + ]); + + TFormFieldGroup _buildAppearanceFieldGroup( + SettingFormFieldGroupContext context, + AppLocalizations appLocalizations) => + TFormFieldGroup( + title: context.getTitle(appLocalizations), + titleKey: context.key, + fields: [ + TFormFieldSelect( + label: appLocalizations.language, + value: _settingsPaneController._useSystemLocale + ? SettingLocale.system + : _nameToLocale[ + _settingsPaneController._appLocalizations.localeName]!, + entries: [ + TMenuEntry( + label: appLocalizations.systemLanguage, + value: SettingLocale.system), + const TMenuEntry(label: 'English', value: SettingLocale.en), + const TMenuEntry(label: '日本語', value: SettingLocale.ja), + const TMenuEntry(label: '简体中文', value: SettingLocale.zhCn), + ], + onSelected: (SettingLocale value) async { + await _settingsPaneController._updateLocale(value); + }, + ), + TFormFieldSelect( + label: appLocalizations.theme, + value: _settingsPaneController._userSettings.theme ?? + ThemeMode.system, + entries: [ + TMenuEntry( + label: appLocalizations.systemTheme, + value: ThemeMode.system), + TMenuEntry( + label: appLocalizations.lightTheme, value: ThemeMode.light), + TMenuEntry( + label: appLocalizations.darkTheme, value: ThemeMode.dark), + ], + onSelected: (ThemeMode value) async { + await _settingsPaneController._updateThemeMode(value); + }, + ) + ]); + + TFormFieldGroup _buildUpdateFieldGroup(SettingFormFieldGroupContext context, + AppLocalizations appLocalizations) => + TFormFieldGroup( + title: context.getTitle(appLocalizations), + titleKey: context.key, + fields: [ + TFormFieldCheckbox( + label: appLocalizations.checkForUpdatesAutomatically, + value: _settingsPaneController + ._userSettings.checkForUpdatesAutomatically ?? + false, + onChanged: + _settingsPaneController._updateCheckForUpdatesAutomatically, + ) + ]); +} + +final _nameToLocale = { + for (SettingLocale locale in SettingLocale.values) locale.name: locale +}; diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/settings_page/sub_navigation_rail.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/settings_page/sub_navigation_rail.dart new file mode 100644 index 0000000000..f099c552f8 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/settings_page/sub_navigation_rail.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../themes/index.dart'; + +import '../../../components/t_tabs/t_tabs.dart'; +import 'setting_form_field_groups.dart'; + +class SubNavigationRail extends ConsumerStatefulWidget { + const SubNavigationRail({super.key, required this.onTabSelected}); + + final void Function(int index, TTab tab) onTabSelected; + + @override + ConsumerState createState() => _SubNavigationRailState(); +} + +class _SubNavigationRailState extends ConsumerState { + @override + Widget build(BuildContext context) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + final appThemeExtension = context.appThemeExtension; + return SizedBox( + width: 140, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + right: BorderSide( + color: appThemeExtension + .settingPageSubNavigationRailDividerColor))), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 24, right: 24, top: 8, bottom: 24), + child: Text( + textAlign: TextAlign.start, + appLocalizations.settings, + style: appThemeExtension.dialogTitleTextStyleLarge), + ), + // Note that we don't change the tab color when the tab is selected + // because we have only a few settings, and the UI will be weired + // if we change the color when the tab is selected. + TTabs(tabs: [ + for (final entry in formFieldGroupToContext.entries) + TTab( + id: entry.key, text: entry.value.getTitle(appLocalizations)) + ], onTabSelected: (index, tab) => widget.onTabSelected(index, tab)), + ], + ), + ), + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_components/user_profile/user_profile.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_components/user_profile/user_profile.dart new file mode 100644 index 0000000000..f352608bf4 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_components/user_profile/user_profile.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../domain/user/models/user.dart'; +import '../../../../../l10n/app_localizations.dart'; +import '../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../../themes/sizes.dart'; +import '../../../../components/t_menu/t_context_menu.dart'; +import 'user_profile_image.dart'; + +class UserProfile extends ConsumerStatefulWidget { + const UserProfile({ + super.key, + required this.user, + this.onEditTap, + }); + + final User user; + final VoidCallback? onEditTap; + + @override + ConsumerState createState() => _UserProfileState(); +} + +class _UserProfileState extends ConsumerState { + @override + Widget build(BuildContext context) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + return _buildProfile(appLocalizations); + } + + Widget _buildProfile(AppLocalizations appLocalizations) { + final user = widget.user; + return IntrinsicHeight( + child: Row( + spacing: 16, + children: [ + UserProfileImage( + user: user, + onEditTap: widget.onEditTap, + ), + Expanded( + child: Padding( + padding: Sizes.paddingV4, + child: SelectionArea( + contextMenuBuilder: buildContextMenuForSelectableRegion, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + ), + Text('${appLocalizations.userId}: ${user.userId}'), + ], + ), + ), + ), + ) + ], + ), + ); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_components/user_profile/user_profile_image.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_components/user_profile/user_profile_image.dart new file mode 100644 index 0000000000..a73a9120de --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_components/user_profile/user_profile_image.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../../domain/user/models/index.dart'; +import '../../../../../l10n/app_localizations.dart'; +import '../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../components/t_avatar/t_avatar.dart'; +import '../../../../components/t_image_viewer/t_image_viewer.dart'; + +class UserProfileImage extends ConsumerStatefulWidget { + const UserProfileImage({super.key, required this.user, this.onEditTap}); + + final User user; + final VoidCallback? onEditTap; + + @override + ConsumerState createState() => _UserProfileImageState(); +} + +class _UserProfileImageState extends ConsumerState { + double _imageOpacity = 0; + + @override + Widget build(BuildContext context) { + final user = widget.user; + final image = user.image; + final avatar = TAvatar( + id: user.userId, + size: TAvatarSize.large, + name: user.name, + image: image, + ); + if (widget.onEditTap case final onEditTap?) { + return _buildEditableAvatar( + ref.watch(appLocalizationsViewModel), onEditTap, avatar); + } + return image == null + ? avatar + : MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + showImageViewerDialog(context, image); + }, + child: avatar, + ), + ); + } + + MouseRegion _buildEditableAvatar(AppLocalizations appLocalizations, + VoidCallback onEditTap, TAvatar avatar) => + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) { + setState(() { + _imageOpacity = 1; + }); + }, + onExit: (_) { + setState(() { + _imageOpacity = 0; + }); + }, + child: GestureDetector( + onTap: onEditTap, + child: Stack( + children: [ + avatar, + Positioned.fill( + child: Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + height: 20, + width: double.infinity, + child: AnimatedOpacity( + opacity: _imageOpacity, + duration: const Duration(milliseconds: 100), + child: DecoratedBox( + decoration: const BoxDecoration( + color: Color.fromARGB(128, 0, 0, 0), + ), + child: Center( + child: Text( + appLocalizations.edit, + style: const TextStyle( + color: Colors.white, fontSize: 14), + ), + ), + ), + ), + ), + ), + ) + ], + ), + ), + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_components/user_profile_image_editor_dialog/user_profile_image_editor_dialog.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_components/user_profile_image_editor_dialog/user_profile_image_editor_dialog.dart new file mode 100644 index 0000000000..1a4185368e --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_components/user_profile_image_editor_dialog/user_profile_image_editor_dialog.dart @@ -0,0 +1,257 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../../../domain/user/models/index.dart'; +import '../../../../../../infra/io/file_utils.dart'; +import '../../../../../../infra/io/io_extensions.dart'; +import '../../../../../../infra/units/math_extensions.dart'; +import '../../../../../l10n/app_localizations.dart'; +import '../../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../../themes/index.dart'; +import '../../../../components/index.dart'; + +const _allowedExtensions = ['png', 'jpg', 'jpeg']; +const _imageSize = 300.0; + +class UserProfileImageEditorDialog extends ConsumerStatefulWidget { + const UserProfileImageEditorDialog({super.key, required this.user}); + + final User user; + + @override + ConsumerState createState() => + _UserProfileImageEditorDialogState(); +} + +class _UserProfileImageEditorDialogState + extends ConsumerState { + ImageProvider? _selectedImage; + bool _flipX = false; + bool _flipY = false; + double _angle = 0; + + @override + void initState() { + super.initState(); + _selectedImage = widget.user.image; + } + + @override + void dispose() { + _selectedImage?.evict(cache: PaintingBinding.instance.imageCache); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final appLocalizations = ref.watch(appLocalizationsViewModel); + final selectedImage = _selectedImage; + final enableOperations = selectedImage != null; + final user = widget.user; + + return Padding( + padding: const EdgeInsets.only(left: 16, top: 8, bottom: 16), + child: Column( + children: [ + Text(appLocalizations.editProfileImage), + Sizes.sizedBoxH8, + Row( + spacing: 16, + children: [ + ClipRRect( + borderRadius: Sizes.borderRadiusCircular4, + child: SizedBox( + width: _imageSize, + height: _imageSize, + child: selectedImage == null + ? TAvatar( + id: user.userId, + name: user.name, + textSize: 125, + ) + : DecoratedBox( + decoration: const BoxDecoration( + color: Colors.black26, + ), + child: Stack( + children: [ + Transform.flip( + flipX: _flipX, + flipY: _flipY, + child: Transform.rotate( + angle: _angle, + child: Image( + image: selectedImage, + width: _imageSize, + height: _imageSize, + fit: BoxFit.contain, + // If false, the image widget will blink + // as the image loads, + // while the image is loaded from the memory or filesystem, + // it should be loaded very quickly. + // so we set it to true to avoiding blinking. + gaplessPlayback: true, + ), + ), + ), + ], + ), + )), + ), + Expanded( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(appLocalizations.brightness), + const Text('50%'), + ], + ), + Slider( + value: 0, + max: 100, + divisions: 1, + onChanged: (value) {}, + ), + const SizedBox( + height: 16, + ), + Text(appLocalizations.rotateAndFlip), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TIconButton.outlined( + theme: theme, + iconData: Symbols.rotate_left_rounded, + containerSize: const Size.square(32), + tooltip: appLocalizations.rotateLeft, + disabled: !enableOperations, + onTap: () { + _angle -= 90.degreesToRadians(); + setState(() {}); + }, + ), + TIconButton.outlined( + theme: theme, + iconData: Symbols.rotate_right_rounded, + containerSize: const Size.square(32), + tooltip: appLocalizations.rotateRight, + disabled: !enableOperations, + onTap: () { + _angle += 90.degreesToRadians(); + setState(() {}); + }, + ), + TIconButton.outlined( + theme: theme, + iconData: Symbols.flip_rounded, + containerSize: const Size.square(32), + tooltip: appLocalizations.flipHorizontally, + disabled: !enableOperations, + onTap: () { + _flipX = !_flipX; + setState(() {}); + }, + ), + TIconButton.outlined( + theme: theme, + iconData: Symbols.flip_rounded, + containerSize: const Size.square(32), + iconRotate: 90.degreesToRadians(), + tooltip: appLocalizations.flipVertically, + disabled: !enableOperations, + onTap: () { + _flipY = !_flipY; + setState(() {}); + }, + ), + ], + ), + ], + ), + ) + ], + ), + Expanded( + child: Align( + alignment: Alignment.bottomRight, + child: _buildActions(context, theme, appLocalizations)), + ), + ], + ), + ); + } + + Widget _buildActions(BuildContext context, ThemeData theme, + AppLocalizations appLocalizations) => + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TTextButton( + containerPadding: Sizes.paddingV4H8, + text: appLocalizations.selectProfileImage, + onTap: () async { + final result = await FileUtils.pickFile( + allowedExtensions: _allowedExtensions, + withReadStream: true); + if (result?.isSinglePick ?? false) { + final file = result!.files[0]; + final bytes = await file.readStream?.toFuture(); + if ((bytes?.length ?? 0) > 0 && + _allowedExtensions.contains(file.extension)) { + unawaited(_selectedImage?.evict( + cache: PaintingBinding.instance.imageCache)); + _selectedImage = ResizeImage(MemoryImage(bytes!), + width: _imageSize.toInt(), + height: _imageSize.toInt(), + policy: ResizeImagePolicy.fit); + setState(() {}); + } + } + }), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TTextButton.outlined( + theme: theme, + text: appLocalizations.cancel, + containerPadding: Sizes.paddingV4H8, + containerWidth: 72, + onTap: () => Navigator.of(context).pop(), + // onTap: createGroupPageController.close, + ), + Sizes.sizedBoxW16, + TTextButton( + // isLoading: createGroupPageController.isCreating, + // disabled: + // createGroupPageController.selectedUserContactIds.length <= 1, + text: appLocalizations.confirm, + containerPadding: Sizes.paddingV4H8, + containerWidth: 72, + onTap: () { + // TODO: appLocalizations.confirm + }, + ), + Sizes.sizedBoxW16 + ], + ), + ], + ); +} + +Future showUserProfileImageEditorDialog( + BuildContext context, User user) => + showSimpleTDialog( + routeName: '/user-profile-image-dialog', + context: context, + width: Sizes.userProfileImageDialogWidth, + height: Sizes.userProfileImageDialogHeight, + child: UserProfileImageEditorDialog( + user: user, + ), + ); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_components/user_profile_popup.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_components/user_profile_popup.dart new file mode 100644 index 0000000000..04ea2747e6 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_components/user_profile_popup.dart @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2019 The Turms Project + * https://github.com/turms-im/turms + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../domain/user/models/index.dart'; +import '../../../../../domain/user/view_models/logged_in_user_info_view_model.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../../themes/index.dart'; + +import '../../../components/t_avatar/t_avatar.dart'; +import '../../../components/t_button/t_text_button.dart'; +import '../../../components/t_image_viewer/t_image_viewer.dart'; +import '../../../components/t_popup/t_popup.dart'; +import '../chat_page/view_models/selected_conversation_view_model.dart'; +import 'user_profile/user_profile.dart'; +import 'user_profile_image_editor_dialog/user_profile_image_editor_dialog.dart'; + +class UserProfilePopup extends ConsumerStatefulWidget { + const UserProfilePopup({ + super.key, + required this.user, + this.imageEditable = false, + this.size = TAvatarSize.medium, + this.popupAnchor = Alignment.topLeft, + this.presence = UserPresence.none, + this.onPresenceSelected, + this.presencePopupOffset, + }); + + final User user; + final bool imageEditable; + final TAvatarSize size; + final Alignment popupAnchor; + final UserPresence presence; + final ValueChanged? onPresenceSelected; + final Offset? presencePopupOffset; + + @override + ConsumerState createState() => _UserProfilePopupState(); +} + +class _UserProfilePopupState extends ConsumerState { + late TPopupController _popupController; + + @override + void initState() { + super.initState(); + _popupController = TPopupController(); + } + + @override + Widget build(BuildContext context) { + final appThemeExtension = context.appThemeExtension; + final appLocalizations = ref.watch(appLocalizationsViewModel); + final user = widget.user; + final image = user.image; + final onPresenceSelected = widget.onPresenceSelected; + final avatar = TAvatar( + id: user.userId, + name: user.name, + size: widget.size, + image: image, + presence: widget.presence, + onPresenceSelected: onPresenceSelected, + presencePopupOffset: widget.presencePopupOffset, + ); + return TPopup( + controller: _popupController, + targetAnchor: Alignment.center, + followerAnchor: widget.popupAnchor, + target: MouseRegion( + cursor: SystemMouseCursors.click, + child: image == null + ? avatar + : GestureDetector( + onTap: () { + showImageViewerDialog(context, image); + }, + child: avatar, + ), + ), + followerBorderRadius: Sizes.borderRadiusCircular4, + follower: SizedBox( + width: Sizes.userProfilePopupWidth, + height: Sizes.userProfilePopupHeight, + child: DecoratedBox( + decoration: appThemeExtension.popupDecoration, + child: Padding( + padding: Sizes.paddingV16H16, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UserProfile( + user: user, + onEditTap: widget.imageEditable + ? _startEditUserProfileImage + : null, + ), + TTextButton( + text: appLocalizations.messages, + onTap: () => _startConversation(user, appLocalizations), + ), + ], + ), + ), + ), + ), + ), + ); + } + + void _startConversation(User user, AppLocalizations appLocalizations) { + _popupController.hidePopover?.call(); + // TODO: Handle the case when the user is a stranger. + final loggedInUserId = ref.read(loggedInUserViewModel)!.userId; + final contact = loggedInUserId == user.userId + ? SystemContact.forFileTransfer(appLocalizations) + : UserContact.fromUser(user, Int64.MIN_VALUE); + ref.read(selectedConversationViewModel.notifier).selectByContact(contact); + } + + void _startEditUserProfileImage() { + _popupController.hidePopover?.call(); + showUserProfileImageEditorDialog(context, widget.user); + } +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_view_models/home_page_tab_view_model.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_view_models/home_page_tab_view_model.dart new file mode 100644 index 0000000000..97b4051806 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/home_page/shared_view_models/home_page_tab_view_model.dart @@ -0,0 +1,6 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../home_page_tab.dart'; + +final homePageTabViewModel = + StateProvider((ref) => HomePageTab.chat); diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/login_page/login_form.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/login_page/login_form.dart new file mode 100644 index 0000000000..83ee4b55cc --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/login_page/login_form.dart @@ -0,0 +1,223 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../domain/app/models/app_settings.dart'; +import '../../../../domain/app/view_models/app_settings_view_model.dart'; +import '../../../../domain/user/managers/user_session_manager.dart'; +import '../../../../domain/user/services/user_service.dart'; +import '../../../../domain/user/view_models/logged_in_user_info_view_model.dart'; +import '../../../../domain/user/view_models/user_login_infos_view_model.dart'; +import '../../../../infra/logging/log_appender_database.dart'; +import '../../../../infra/logging/logger.dart'; +import '../../../../infra/sqlite/app_database.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../l10n/view_models/app_localizations_view_model.dart'; +import '../../../themes/app_theme_extension.dart'; +import '../../../themes/sizes.dart'; +import '../../components/index.dart'; + +class LoginForm extends ConsumerStatefulWidget { + const LoginForm({super.key}); + + @override + ConsumerState createState() => _LoginFormState(); +} + +class _LoginFormState extends ConsumerState { + final _formKey = GlobalKey(); + + bool _isWaitingLoginRequest = false; + + late Int64 _userId; + late String _password; + bool? _rememberMe; + + LogAppenderDatabase? _logAppenderDatabase; + + @override + Widget build(BuildContext context) { + final appLocalizations = ref.watch(appLocalizationsViewModel); + final appSettings = ref.watch(appSettingsViewModel)!; + final userLoginInfos = ref.watch(userLoginInfosViewModel); + ref.listen(loggedInUserViewModel, (_, loggedInUser) { + final appender = _logAppenderDatabase; + if (loggedInUser == null && appender != null) { + logger.removeAppender(appender); + _logAppenderDatabase = null; + } + }); + return _buildView( + context.theme, appLocalizations, appSettings, userLoginInfos); + } + + void _submit() { + if (_isWaitingLoginRequest || !_formKey.currentState!.validate()) { + return; + } + _formKey.currentState!.save(); + _isWaitingLoginRequest = true; + setState(() {}); + _login(); + } + + Future _login() async { + final user = await UserService.login(_userId); + userSessionManager = UserSessionManager(userId: _userId); + await userSessionManager.onLoggedIn( + ref: ref, rememberMe: _rememberMe!, user: user, password: _password); + if (!mounted) { + return; + } + _isWaitingLoginRequest = false; + setState(() {}); + } + + void _setUserId(String? newValue) { + if (newValue != null) { + _userId = Int64.parseInt(newValue); + } + } + + void _setPassword(String? newValue) { + if (newValue != null) { + _password = newValue; + } + } +} + +extension _LoginFormView on _LoginFormState { + Widget _buildView(ThemeData theme, AppLocalizations appLocalizations, + AppSettings appSettings, List userLoginInfos) { + final localizations = appLocalizations; + _rememberMe ??= appSettings.getRememberMe() ?? false; + final userLoginInfo = userLoginInfos.firstOrNull; + final borderError = UnderlineInputBorder( + borderSide: BorderSide(color: theme.colorScheme.error)); + return FocusScope( + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.enter) { + _submit(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + autofocus: true, + initialValue: userLoginInfo?.userId.toString(), + cursorColor: theme.primaryColor, + // Length for the max digit of 8-bytes number + maxLength: 20, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + // fix height regardless of whether or not an error is displayed. + helperText: ' ', + // hide length counter + counterText: '', + prefixIcon: Icon(Symbols.person_outline_rounded, + color: theme.inputDecorationTheme.iconColor), + // color: ThemeConfig.textColorSecondary), + isCollapsed: true, + contentPadding: Sizes.paddingV16, + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: theme.dividerColor)), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: theme.primaryColor, + )), + errorBorder: borderError, + focusedErrorBorder: borderError, + hintText: localizations.userId), + onFieldSubmitted: (value) => _submit(), + onSaved: _setUserId, + validator: (value) { + if (value == null || value.isEmpty) { + return localizations.pleaseEnterUserId; + } + return null; + }, + ), + TextFormField( + initialValue: userLoginInfo?.password, + cursorColor: theme.primaryColor, + obscureText: true, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + // fix height regardless of whether or not an error is displayed. + helperText: ' ', + // contentPadding: EdgeInsets.only(bottom: 8), + prefixIcon: Icon(Symbols.lock_outline_rounded, + color: theme.inputDecorationTheme.iconColor), + isCollapsed: true, + contentPadding: Sizes.paddingV16, + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: theme.dividerColor)), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: theme.primaryColor, + )), + errorBorder: borderError, + focusedErrorBorder: borderError, + hintText: localizations.userPassword), + onFieldSubmitted: (value) => _submit(), + onSaved: _setPassword, + validator: (value) { + if (value == null || value.isEmpty) { + return localizations.pleaseEnterPassword; + } + return null; + }, + ), + Sizes.sizedBoxH12, + TCheckbox( + _rememberMe!, + localizations.rememberMe, + onCheckedChanged: (bool checked) { + _rememberMe = checked; + }, + ), + Sizes.sizedBoxH32, + _buildLoginButton(_isWaitingLoginRequest, localizations, theme) + ], + )), + ); + } + + Widget _buildLoginButton(bool isWaitingLoginRequest, + AppLocalizations localizations, ThemeData theme) => + FilledButton( + onPressed: isWaitingLoginRequest ? null : _submit, + style: FilledButton.styleFrom( + minimumSize: const Size(0, 56), + shape: const RoundedRectangleBorder( + borderRadius: Sizes.borderRadiusCircular4, + ), + disabledBackgroundColor: theme.disabledColor, + ), + child: isWaitingLoginRequest + ? const SizedBox( + height: 24, + width: 24, + child: RepaintBoundary( + child: CircularProgressIndicator( + color: Colors.white, + ), + ), + ) + : Text( + localizations.login, + style: theme.textTheme.labelMedium! + .copyWith(fontSize: 20, color: Colors.white), + ), + ); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/login_page/login_page.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/login_page/login_page.dart new file mode 100644 index 0000000000..5ec4928694 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/login_page/login_page.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../../../infra/assets/assets.gen.dart'; +import '../../components/t_title_bar/t_title_bar.dart'; +import '../../components/t_window_control_zone/t_window_control_zone.dart'; +import 'login_form.dart'; + +class LoginPage extends StatelessWidget { + const LoginPage({super.key}); + + @override + Widget build(BuildContext context) => Stack(children: [ + const TWindowControlZone(toggleMaximizeOnDoubleTap: false), + Padding( + padding: + const EdgeInsets.only(left: 36, right: 36, top: 36, bottom: 24), + child: ColoredBox( + color: Colors.white, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, borderRadius: BorderRadius.circular(20)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 32, + children: [ + SvgPicture.asset( + width: 320, + Assets.images.logo, + ), + const LoginForm(), + ], + ), + ), + ), + ), + const TTitleBar(displayCloseOnly: true), + ]); +} diff --git a/turms-chat-demo-flutter/lib/ui/desktop/pages/view_models/sub_navigation_rail_width_view_model.dart b/turms-chat-demo-flutter/lib/ui/desktop/pages/view_models/sub_navigation_rail_width_view_model.dart new file mode 100644 index 0000000000..8b222b9099 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/desktop/pages/view_models/sub_navigation_rail_width_view_model.dart @@ -0,0 +1,21 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../themes/sizes.dart'; + +class SubNavigationRailWidthViewModelNotifier extends Notifier { + @override + double build() => Sizes.subNavigationRailWidth; + + void update(double width) { + final newWidth = width + .clamp(Sizes.subNavigationRailMinWidth, Sizes.subNavigationRailMaxWidth) + .roundToDouble(); + if (newWidth != state) { + state = newWidth; + } + } +} + +final subNavigationRailWidthViewModel = + NotifierProvider( + SubNavigationRailWidthViewModelNotifier.new); diff --git a/turms-chat-demo-flutter/lib/ui/l10n/app_localizations.dart b/turms-chat-demo-flutter/lib/ui/l10n/app_localizations.dart new file mode 100644 index 0000000000..c1e0f24899 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/l10n/app_localizations.dart @@ -0,0 +1,911 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_ja.dart'; +import 'app_localizations_zh.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations)!; + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('ja'), + Locale('zh') + ]; + + /// No description provided for @about. + /// + /// In en, this message translates to: + /// **'About'** + String get about; + + /// No description provided for @accept. + /// + /// In en, this message translates to: + /// **'Accept'** + String get accept; + + /// No description provided for @actionOnClose. + /// + /// In en, this message translates to: + /// **'Action on Close'** + String get actionOnClose; + + /// No description provided for @actions. + /// + /// In en, this message translates to: + /// **'Actions'** + String get actions; + + /// No description provided for @addContact. + /// + /// In en, this message translates to: + /// **'Add Contact'** + String get addContact; + + /// No description provided for @addNewMember. + /// + /// In en, this message translates to: + /// **'Add New Member'** + String get addNewMember; + + /// No description provided for @addNewRelationship. + /// + /// In en, this message translates to: + /// **'Add New Relationship'** + String get addNewRelationship; + + /// No description provided for @alreadyLatestVersion. + /// + /// In en, this message translates to: + /// **'This is the latest version'** + String get alreadyLatestVersion; + + /// No description provided for @alwaysOnTopDisable. + /// + /// In en, this message translates to: + /// **'Disable Always on Top'** + String get alwaysOnTopDisable; + + /// No description provided for @alwaysOnTopEnable. + /// + /// In en, this message translates to: + /// **'Always on Top'** + String get alwaysOnTopEnable; + + /// No description provided for @appearance. + /// + /// In en, this message translates to: + /// **'Appearance'** + String get appearance; + + /// No description provided for @audio. + /// + /// In en, this message translates to: + /// **'Audio'** + String get audio; + + /// No description provided for @autoLogin. + /// + /// In en, this message translates to: + /// **'Auto Login'** + String get autoLogin; + + /// No description provided for @brightness. + /// + /// In en, this message translates to: + /// **'Brightness'** + String get brightness; + + /// No description provided for @cancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get cancel; + + /// No description provided for @changingStatusToAwayWhenInactiveForMinutes. + /// + /// In en, this message translates to: + /// **'Changing status to \"Away\" when inactive for %% minutes'** + String get changingStatusToAwayWhenInactiveForMinutes; + + /// No description provided for @chatHistory. + /// + /// In en, this message translates to: + /// **'Chat History'** + String get chatHistory; + + /// No description provided for @chatInfo. + /// + /// In en, this message translates to: + /// **'Chat Info'** + String get chatInfo; + + /// No description provided for @chats. + /// + /// In en, this message translates to: + /// **'Chats'** + String get chats; + + /// No description provided for @checkForUpdatesAutomatically. + /// + /// In en, this message translates to: + /// **'Check for Updates Automatically'** + String get checkForUpdatesAutomatically; + + /// No description provided for @clearChatHistory. + /// + /// In en, this message translates to: + /// **'Clear Chat History'** + String get clearChatHistory; + + /// No description provided for @close. + /// + /// In en, this message translates to: + /// **'Close'** + String get close; + + /// No description provided for @confirm. + /// + /// In en, this message translates to: + /// **'Confirm'** + String get confirm; + + /// No description provided for @contacts. + /// + /// In en, this message translates to: + /// **'Contacts'** + String get contacts; + + /// No description provided for @create. + /// + /// In en, this message translates to: + /// **'Create'** + String get create; + + /// No description provided for @createGroup. + /// + /// In en, this message translates to: + /// **'Create Group'** + String get createGroup; + + /// No description provided for @darkTheme. + /// + /// In en, this message translates to: + /// **'Dark'** + String get darkTheme; + + /// No description provided for @delete. + /// + /// In en, this message translates to: + /// **'Delete'** + String get delete; + + /// No description provided for @deleteChat. + /// + /// In en, this message translates to: + /// **'Delete Chat'** + String get deleteChat; + + /// No description provided for @disableNewMessageNotification. + /// + /// In en, this message translates to: + /// **'Mute'** + String get disableNewMessageNotification; + + /// No description provided for @downloadCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get downloadCancel; + + /// No description provided for @downloadPause. + /// + /// In en, this message translates to: + /// **'Pause'** + String get downloadPause; + + /// No description provided for @downloadStart. + /// + /// In en, this message translates to: + /// **'Start'** + String get downloadStart; + + /// No description provided for @draft. + /// + /// In en, this message translates to: + /// **'Draft'** + String get draft; + + /// No description provided for @dropFilesHere. + /// + /// In en, this message translates to: + /// **'Drop files here'** + String get dropFilesHere; + + /// No description provided for @edit. + /// + /// In en, this message translates to: + /// **'Edit'** + String get edit; + + /// No description provided for @editProfileImage. + /// + /// In en, this message translates to: + /// **'Edit Profile Image'** + String get editProfileImage; + + /// No description provided for @enableNewMessageNotification. + /// + /// In en, this message translates to: + /// **'Unmute'** + String get enableNewMessageNotification; + + /// No description provided for @error. + /// + /// In en, this message translates to: + /// **'Error'** + String get error; + + /// No description provided for @exit. + /// + /// In en, this message translates to: + /// **'Exit'** + String get exit; + + /// No description provided for @failedToDownload. + /// + /// In en, this message translates to: + /// **'Failed to download'** + String get failedToDownload; + + /// No description provided for @failedToDownloadFileTooLarge. + /// + /// In en, this message translates to: + /// **'Failed to download file: File is larger than {size}MB'** + String failedToDownloadFileTooLarge(Object size); + + /// No description provided for @failedToSendImageInvalidUrl. + /// + /// In en, this message translates to: + /// **'Failed to send image: Invalid URL'** + String get failedToSendImageInvalidUrl; + + /// No description provided for @failedToUpdateSettings. + /// + /// In en, this message translates to: + /// **'Failed to update settings: {error}'** + String failedToUpdateSettings(Object error); + + /// No description provided for @file. + /// + /// In en, this message translates to: + /// **'File'** + String get file; + + /// No description provided for @fileName. + /// + /// In en, this message translates to: + /// **'Name'** + String get fileName; + + /// No description provided for @fileSize. + /// + /// In en, this message translates to: + /// **'Size'** + String get fileSize; + + /// No description provided for @fileTransfer. + /// + /// In en, this message translates to: + /// **'File Transfer'** + String get fileTransfer; + + /// No description provided for @fileType. + /// + /// In en, this message translates to: + /// **'Type'** + String get fileType; + + /// No description provided for @fileUploadDate. + /// + /// In en, this message translates to: + /// **'Upload Date'** + String get fileUploadDate; + + /// No description provided for @fileUploader. + /// + /// In en, this message translates to: + /// **'Uploader'** + String get fileUploader; + + /// No description provided for @files. + /// + /// In en, this message translates to: + /// **'Files'** + String get files; + + /// No description provided for @flipHorizontally. + /// + /// In en, this message translates to: + /// **'Flip Horizontally'** + String get flipHorizontally; + + /// No description provided for @flipVertically. + /// + /// In en, this message translates to: + /// **'Flip Vertically'** + String get flipVertically; + + /// No description provided for @friendRequests. + /// + /// In en, this message translates to: + /// **'Friend Requests'** + String get friendRequests; + + /// No description provided for @goToChatPage. + /// + /// In en, this message translates to: + /// **'Go to Chat Page'** + String get goToChatPage; + + /// No description provided for @goToContactsPage. + /// + /// In en, this message translates to: + /// **'Go to Contacts Page'** + String get goToContactsPage; + + /// No description provided for @goToFilesPage. + /// + /// In en, this message translates to: + /// **'Go to Files Page'** + String get goToFilesPage; + + /// No description provided for @groupId. + /// + /// In en, this message translates to: + /// **'Group ID'** + String get groupId; + + /// No description provided for @groupMembershipRequests. + /// + /// In en, this message translates to: + /// **'Group Membership Requests'** + String get groupMembershipRequests; + + /// No description provided for @groups. + /// + /// In en, this message translates to: + /// **'Groups'** + String get groups; + + /// No description provided for @image. + /// + /// In en, this message translates to: + /// **'Image'** + String get image; + + /// No description provided for @joinGroup. + /// + /// In en, this message translates to: + /// **'Join Group'** + String get joinGroup; + + /// No description provided for @language. + /// + /// In en, this message translates to: + /// **'Language'** + String get language; + + /// No description provided for @launchAndExit. + /// + /// In en, this message translates to: + /// **'Launch and Exit'** + String get launchAndExit; + + /// No description provided for @launchOnStartup. + /// + /// In en, this message translates to: + /// **'Run on Startup'** + String get launchOnStartup; + + /// No description provided for @leaveGroup. + /// + /// In en, this message translates to: + /// **'Leave Group'** + String get leaveGroup; + + /// No description provided for @lightTheme. + /// + /// In en, this message translates to: + /// **'Light'** + String get lightTheme; + + /// No description provided for @loading. + /// + /// In en, this message translates to: + /// **'Loading'** + String get loading; + + /// No description provided for @logOut. + /// + /// In en, this message translates to: + /// **'Log Out'** + String get logOut; + + /// No description provided for @login. + /// + /// In en, this message translates to: + /// **'Login'** + String get login; + + /// No description provided for @lowDiskSpace. + /// + /// In en, this message translates to: + /// **'Low Disk Space'** + String get lowDiskSpace; + + /// No description provided for @lowDiskSpacePrompt. + /// + /// In en, this message translates to: + /// **'The disk space is lower than {space}MB. Please delete some files or applications to free up space.'** + String lowDiskSpacePrompt(Object space); + + /// No description provided for @maximize. + /// + /// In en, this message translates to: + /// **'Maximize'** + String get maximize; + + /// No description provided for @message. + /// + /// In en, this message translates to: + /// **'Message'** + String get message; + + /// No description provided for @messages. + /// + /// In en, this message translates to: + /// **'Messages'** + String get messages; + + /// No description provided for @minimize. + /// + /// In en, this message translates to: + /// **'Minimize'** + String get minimize; + + /// No description provided for @minimizeToTray. + /// + /// In en, this message translates to: + /// **'Minimize to Tray'** + String get minimizeToTray; + + /// No description provided for @muteNotifications. + /// + /// In en, this message translates to: + /// **'Mute Notifications'** + String get muteNotifications; + + /// No description provided for @network. + /// + /// In en, this message translates to: + /// **'Network'** + String get network; + + /// No description provided for @newMessageNotification. + /// + /// In en, this message translates to: + /// **'New Message Notification'** + String get newMessageNotification; + + /// No description provided for @noMatchingGroupMembersFound. + /// + /// In en, this message translates to: + /// **'No matching members found'** + String get noMatchingGroupMembersFound; + + /// No description provided for @noResultsFound. + /// + /// In en, this message translates to: + /// **'No results found'** + String get noResultsFound; + + /// No description provided for @none. + /// + /// In en, this message translates to: + /// **'None'** + String get none; + + /// No description provided for @notifications. + /// + /// In en, this message translates to: + /// **'Notifications'** + String get notifications; + + /// No description provided for @openAboutDialog. + /// + /// In en, this message translates to: + /// **'Open About Dialog'** + String get openAboutDialog; + + /// No description provided for @openFolder. + /// + /// In en, this message translates to: + /// **'Open Folder'** + String get openFolder; + + /// No description provided for @openSettingsDialog. + /// + /// In en, this message translates to: + /// **'Open Settings Dialog'** + String get openSettingsDialog; + + /// No description provided for @pin. + /// + /// In en, this message translates to: + /// **'Pin'** + String get pin; + + /// No description provided for @pleaseEnterPassword. + /// + /// In en, this message translates to: + /// **'Please enter password'** + String get pleaseEnterPassword; + + /// No description provided for @pleaseEnterUserId. + /// + /// In en, this message translates to: + /// **'Please enter user ID'** + String get pleaseEnterUserId; + + /// No description provided for @progress. + /// + /// In en, this message translates to: + /// **'Progress'** + String get progress; + + /// No description provided for @relatedMessages. + /// + /// In en, this message translates to: + /// **'{count} related messages'** + String relatedMessages(Object count); + + /// No description provided for @rememberMe. + /// + /// In en, this message translates to: + /// **'Remember Me'** + String get rememberMe; + + /// No description provided for @removeAttachment. + /// + /// In en, this message translates to: + /// **'Remove Attachment'** + String get removeAttachment; + + /// No description provided for @requestNotification. + /// + /// In en, this message translates to: + /// **'Request Notification'** + String get requestNotification; + + /// No description provided for @reset. + /// + /// In en, this message translates to: + /// **'Reset'** + String get reset; + + /// No description provided for @restore. + /// + /// In en, this message translates to: + /// **'Restore'** + String get restore; + + /// No description provided for @rotateAndFlip. + /// + /// In en, this message translates to: + /// **'Rotate & Flip'** + String get rotateAndFlip; + + /// No description provided for @rotateLeft. + /// + /// In en, this message translates to: + /// **'Rotate Left'** + String get rotateLeft; + + /// No description provided for @rotateRight. + /// + /// In en, this message translates to: + /// **'Rotate Right'** + String get rotateRight; + + /// No description provided for @search. + /// + /// In en, this message translates to: + /// **'Search'** + String get search; + + /// No description provided for @searchStickers. + /// + /// In en, this message translates to: + /// **'Search Stickers'** + String get searchStickers; + + /// No description provided for @selectProfileImage. + /// + /// In en, this message translates to: + /// **'Select Image'** + String get selectProfileImage; + + /// No description provided for @selectedContacts. + /// + /// In en, this message translates to: + /// **'Selected Contacts'** + String get selectedContacts; + + /// No description provided for @send. + /// + /// In en, this message translates to: + /// **'Send'** + String get send; + + /// No description provided for @sendMessage. + /// + /// In en, this message translates to: + /// **'Send Message'** + String get sendMessage; + + /// No description provided for @settings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settings; + + /// No description provided for @shortcuts. + /// + /// In en, this message translates to: + /// **'Shortcuts'** + String get shortcuts; + + /// No description provided for @status. + /// + /// In en, this message translates to: + /// **'Status'** + String get status; + + /// No description provided for @sticker. + /// + /// In en, this message translates to: + /// **'Sticker'** + String get sticker; + + /// No description provided for @systemLanguage. + /// + /// In en, this message translates to: + /// **'System'** + String get systemLanguage; + + /// No description provided for @systemTheme. + /// + /// In en, this message translates to: + /// **'System'** + String get systemTheme; + + /// No description provided for @theme. + /// + /// In en, this message translates to: + /// **'Theme'** + String get theme; + + /// No description provided for @today. + /// + /// In en, this message translates to: + /// **'Today'** + String get today; + + /// No description provided for @unpin. + /// + /// In en, this message translates to: + /// **'Unpin'** + String get unpin; + + /// No description provided for @update. + /// + /// In en, this message translates to: + /// **'Update'** + String get update; + + /// No description provided for @userId. + /// + /// In en, this message translates to: + /// **'User ID'** + String get userId; + + /// No description provided for @userPassword. + /// + /// In en, this message translates to: + /// **'Password'** + String get userPassword; + + /// No description provided for @userPresenceAppearOffline. + /// + /// In en, this message translates to: + /// **'Appear Offline'** + String get userPresenceAppearOffline; + + /// No description provided for @userPresenceAvailable. + /// + /// In en, this message translates to: + /// **'Available'** + String get userPresenceAvailable; + + /// No description provided for @userPresenceAway. + /// + /// In en, this message translates to: + /// **'Away'** + String get userPresenceAway; + + /// No description provided for @userPresenceBusy. + /// + /// In en, this message translates to: + /// **'Busy'** + String get userPresenceBusy; + + /// No description provided for @userPresenceDoNotDisturb. + /// + /// In en, this message translates to: + /// **'Do Not Disturb'** + String get userPresenceDoNotDisturb; + + /// No description provided for @version. + /// + /// In en, this message translates to: + /// **'Version'** + String get version; + + /// No description provided for @video. + /// + /// In en, this message translates to: + /// **'Video'** + String get video; + + /// No description provided for @videoNotFound. + /// + /// In en, this message translates to: + /// **'Video not found'** + String get videoNotFound; + + /// No description provided for @yesterday. + /// + /// In en, this message translates to: + /// **'Yesterday'** + String get yesterday; + + /// No description provided for @youtube. + /// + /// In en, this message translates to: + /// **'YouTube'** + String get youtube; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'ja', 'zh'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'ja': + return AppLocalizationsJa(); + case 'zh': + return AppLocalizationsZh(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); +} diff --git a/turms-chat-demo-flutter/lib/ui/l10n/app_localizations_en.dart b/turms-chat-demo-flutter/lib/ui/l10n/app_localizations_en.dart new file mode 100644 index 0000000000..0f4ebe20f9 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/l10n/app_localizations_en.dart @@ -0,0 +1,404 @@ +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get about => 'About'; + + @override + String get accept => 'Accept'; + + @override + String get actionOnClose => 'Action on Close'; + + @override + String get actions => 'Actions'; + + @override + String get addContact => 'Add Contact'; + + @override + String get addNewMember => 'Add New Member'; + + @override + String get addNewRelationship => 'Add New Relationship'; + + @override + String get alreadyLatestVersion => 'This is the latest version'; + + @override + String get alwaysOnTopDisable => 'Disable Always on Top'; + + @override + String get alwaysOnTopEnable => 'Always on Top'; + + @override + String get appearance => 'Appearance'; + + @override + String get audio => 'Audio'; + + @override + String get autoLogin => 'Auto Login'; + + @override + String get brightness => 'Brightness'; + + @override + String get cancel => 'Cancel'; + + @override + String get changingStatusToAwayWhenInactiveForMinutes => + 'Changing status to \"Away\" when inactive for %% minutes'; + + @override + String get chatHistory => 'Chat History'; + + @override + String get chatInfo => 'Chat Info'; + + @override + String get chats => 'Chats'; + + @override + String get checkForUpdatesAutomatically => 'Check for Updates Automatically'; + + @override + String get clearChatHistory => 'Clear Chat History'; + + @override + String get close => 'Close'; + + @override + String get confirm => 'Confirm'; + + @override + String get contacts => 'Contacts'; + + @override + String get create => 'Create'; + + @override + String get createGroup => 'Create Group'; + + @override + String get darkTheme => 'Dark'; + + @override + String get delete => 'Delete'; + + @override + String get deleteChat => 'Delete Chat'; + + @override + String get disableNewMessageNotification => 'Mute'; + + @override + String get downloadCancel => 'Cancel'; + + @override + String get downloadPause => 'Pause'; + + @override + String get downloadStart => 'Start'; + + @override + String get draft => 'Draft'; + + @override + String get dropFilesHere => 'Drop files here'; + + @override + String get edit => 'Edit'; + + @override + String get editProfileImage => 'Edit Profile Image'; + + @override + String get enableNewMessageNotification => 'Unmute'; + + @override + String get error => 'Error'; + + @override + String get exit => 'Exit'; + + @override + String get failedToDownload => 'Failed to download'; + + @override + String failedToDownloadFileTooLarge(Object size) { + return 'Failed to download file: File is larger than ${size}MB'; + } + + @override + String get failedToSendImageInvalidUrl => 'Failed to send image: Invalid URL'; + + @override + String failedToUpdateSettings(Object error) { + return 'Failed to update settings: $error'; + } + + @override + String get file => 'File'; + + @override + String get fileName => 'Name'; + + @override + String get fileSize => 'Size'; + + @override + String get fileTransfer => 'File Transfer'; + + @override + String get fileType => 'Type'; + + @override + String get fileUploadDate => 'Upload Date'; + + @override + String get fileUploader => 'Uploader'; + + @override + String get files => 'Files'; + + @override + String get flipHorizontally => 'Flip Horizontally'; + + @override + String get flipVertically => 'Flip Vertically'; + + @override + String get friendRequests => 'Friend Requests'; + + @override + String get goToChatPage => 'Go to Chat Page'; + + @override + String get goToContactsPage => 'Go to Contacts Page'; + + @override + String get goToFilesPage => 'Go to Files Page'; + + @override + String get groupId => 'Group ID'; + + @override + String get groupMembershipRequests => 'Group Membership Requests'; + + @override + String get groups => 'Groups'; + + @override + String get image => 'Image'; + + @override + String get joinGroup => 'Join Group'; + + @override + String get language => 'Language'; + + @override + String get launchAndExit => 'Launch and Exit'; + + @override + String get launchOnStartup => 'Run on Startup'; + + @override + String get leaveGroup => 'Leave Group'; + + @override + String get lightTheme => 'Light'; + + @override + String get loading => 'Loading'; + + @override + String get logOut => 'Log Out'; + + @override + String get login => 'Login'; + + @override + String get lowDiskSpace => 'Low Disk Space'; + + @override + String lowDiskSpacePrompt(Object space) { + return 'The disk space is lower than ${space}MB. Please delete some files or applications to free up space.'; + } + + @override + String get maximize => 'Maximize'; + + @override + String get message => 'Message'; + + @override + String get messages => 'Messages'; + + @override + String get minimize => 'Minimize'; + + @override + String get minimizeToTray => 'Minimize to Tray'; + + @override + String get muteNotifications => 'Mute Notifications'; + + @override + String get network => 'Network'; + + @override + String get newMessageNotification => 'New Message Notification'; + + @override + String get noMatchingGroupMembersFound => 'No matching members found'; + + @override + String get noResultsFound => 'No results found'; + + @override + String get none => 'None'; + + @override + String get notifications => 'Notifications'; + + @override + String get openAboutDialog => 'Open About Dialog'; + + @override + String get openFolder => 'Open Folder'; + + @override + String get openSettingsDialog => 'Open Settings Dialog'; + + @override + String get pin => 'Pin'; + + @override + String get pleaseEnterPassword => 'Please enter password'; + + @override + String get pleaseEnterUserId => 'Please enter user ID'; + + @override + String get progress => 'Progress'; + + @override + String relatedMessages(Object count) { + return '$count related messages'; + } + + @override + String get rememberMe => 'Remember Me'; + + @override + String get removeAttachment => 'Remove Attachment'; + + @override + String get requestNotification => 'Request Notification'; + + @override + String get reset => 'Reset'; + + @override + String get restore => 'Restore'; + + @override + String get rotateAndFlip => 'Rotate & Flip'; + + @override + String get rotateLeft => 'Rotate Left'; + + @override + String get rotateRight => 'Rotate Right'; + + @override + String get search => 'Search'; + + @override + String get searchStickers => 'Search Stickers'; + + @override + String get selectProfileImage => 'Select Image'; + + @override + String get selectedContacts => 'Selected Contacts'; + + @override + String get send => 'Send'; + + @override + String get sendMessage => 'Send Message'; + + @override + String get settings => 'Settings'; + + @override + String get shortcuts => 'Shortcuts'; + + @override + String get status => 'Status'; + + @override + String get sticker => 'Sticker'; + + @override + String get systemLanguage => 'System'; + + @override + String get systemTheme => 'System'; + + @override + String get theme => 'Theme'; + + @override + String get today => 'Today'; + + @override + String get unpin => 'Unpin'; + + @override + String get update => 'Update'; + + @override + String get userId => 'User ID'; + + @override + String get userPassword => 'Password'; + + @override + String get userPresenceAppearOffline => 'Appear Offline'; + + @override + String get userPresenceAvailable => 'Available'; + + @override + String get userPresenceAway => 'Away'; + + @override + String get userPresenceBusy => 'Busy'; + + @override + String get userPresenceDoNotDisturb => 'Do Not Disturb'; + + @override + String get version => 'Version'; + + @override + String get video => 'Video'; + + @override + String get videoNotFound => 'Video not found'; + + @override + String get yesterday => 'Yesterday'; + + @override + String get youtube => 'YouTube'; +} diff --git a/turms-chat-demo-flutter/lib/ui/l10n/app_localizations_ja.dart b/turms-chat-demo-flutter/lib/ui/l10n/app_localizations_ja.dart new file mode 100644 index 0000000000..397f28839f --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/l10n/app_localizations_ja.dart @@ -0,0 +1,404 @@ +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Japanese (`ja`). +class AppLocalizationsJa extends AppLocalizations { + AppLocalizationsJa([String locale = 'ja']) : super(locale); + + @override + String get about => '概要'; + + @override + String get accept => '受け入れる'; + + @override + String get actionOnClose => '閉じるときのアクション'; + + @override + String get actions => 'アクション'; + + @override + String get addContact => '連絡先を追加'; + + @override + String get addNewMember => '新しいメンバーを追加'; + + @override + String get addNewRelationship => '新しい関係を追加'; + + @override + String get alreadyLatestVersion => 'これが最新バージョンです'; + + @override + String get alwaysOnTopDisable => '常に最前面を無効にする'; + + @override + String get alwaysOnTopEnable => '常に最前面'; + + @override + String get appearance => '外観'; + + @override + String get audio => 'オーディオ'; + + @override + String get autoLogin => '自動ログイン'; + + @override + String get brightness => '明るさ'; + + @override + String get cancel => 'キャンセル'; + + @override + String get changingStatusToAwayWhenInactiveForMinutes => + '%% 分間非アクティブのときに「離席」にステータスを変更'; + + @override + String get chatHistory => 'チャット履歴'; + + @override + String get chatInfo => 'チャット情報'; + + @override + String get chats => 'チャット'; + + @override + String get checkForUpdatesAutomatically => '自動的に更新を確認'; + + @override + String get clearChatHistory => 'チャット履歴をクリア'; + + @override + String get close => '閉じる'; + + @override + String get confirm => '確認'; + + @override + String get contacts => '連絡先'; + + @override + String get create => '作成'; + + @override + String get createGroup => 'グループを作成'; + + @override + String get darkTheme => 'ダーク'; + + @override + String get delete => '削除'; + + @override + String get deleteChat => 'チャットを削除'; + + @override + String get disableNewMessageNotification => 'ミュート'; + + @override + String get downloadCancel => 'キャンセル'; + + @override + String get downloadPause => '一時停止'; + + @override + String get downloadStart => '開始'; + + @override + String get draft => '下書き'; + + @override + String get dropFilesHere => 'ここにファイルをドロップ'; + + @override + String get edit => '編集'; + + @override + String get editProfileImage => 'プロフィール画像を編集'; + + @override + String get enableNewMessageNotification => 'ミュート解除'; + + @override + String get error => 'エラー'; + + @override + String get exit => '終了'; + + @override + String get failedToDownload => 'ダウンロードに失敗しました'; + + @override + String failedToDownloadFileTooLarge(Object size) { + return 'ダウンロードに失敗しました: ファイルが ${size}MB より大きい'; + } + + @override + String get failedToSendImageInvalidUrl => '画像の送信に失敗しました: 無効なURL'; + + @override + String failedToUpdateSettings(Object error) { + return '設定の更新に失敗しました: $error'; + } + + @override + String get file => 'ファイル'; + + @override + String get fileName => '名前'; + + @override + String get fileSize => 'サイズ'; + + @override + String get fileTransfer => 'ファイル転送'; + + @override + String get fileType => 'タイプ'; + + @override + String get fileUploadDate => 'アップロード日'; + + @override + String get fileUploader => 'アップローダー'; + + @override + String get files => 'ファイル'; + + @override + String get flipHorizontally => '水平方向に反転'; + + @override + String get flipVertically => '垂直方向に反転'; + + @override + String get friendRequests => '友達リクエスト'; + + @override + String get goToChatPage => 'チャットページに移動'; + + @override + String get goToContactsPage => '連絡先ページに移動'; + + @override + String get goToFilesPage => 'ファイルページに移動'; + + @override + String get groupId => 'グループID'; + + @override + String get groupMembershipRequests => 'グループメンバーシップリクエスト'; + + @override + String get groups => 'グループ'; + + @override + String get image => '画像'; + + @override + String get joinGroup => 'グループに参加'; + + @override + String get language => '言語'; + + @override + String get launchAndExit => '起動と終了'; + + @override + String get launchOnStartup => '起動時に実行'; + + @override + String get leaveGroup => 'グループを離れる'; + + @override + String get lightTheme => 'ライト'; + + @override + String get loading => '読み込み中'; + + @override + String get logOut => 'ログアウト'; + + @override + String get login => 'ログイン'; + + @override + String get lowDiskSpace => 'ディスクスペースが不足しています'; + + @override + String lowDiskSpacePrompt(Object space) { + return '${space}MB よりもディスクスペースが少なくなっています。スペースを空けるためにいくつかのファイルやアプリケーションを削除してください。'; + } + + @override + String get maximize => '最大化'; + + @override + String get message => 'メッセージ'; + + @override + String get messages => 'メッセージ'; + + @override + String get minimize => '最小化'; + + @override + String get minimizeToTray => 'トレイに最小化'; + + @override + String get muteNotifications => '通知をミュート'; + + @override + String get network => 'ネットワーク'; + + @override + String get newMessageNotification => '新しいメッセージ通知'; + + @override + String get noMatchingGroupMembersFound => '一致するメンバーが見つかりませんでした'; + + @override + String get noResultsFound => '結果が見つかりませんでした'; + + @override + String get none => 'なし'; + + @override + String get notifications => '通知'; + + @override + String get openAboutDialog => '概要ダイアログを開く'; + + @override + String get openFolder => 'フォルダーを開く'; + + @override + String get openSettingsDialog => '設定ダイアログを開く'; + + @override + String get pin => 'ピン留め'; + + @override + String get pleaseEnterPassword => 'パスワードを入力してください'; + + @override + String get pleaseEnterUserId => 'ユーザーIDを入力してください'; + + @override + String get progress => '進行状況'; + + @override + String relatedMessages(Object count) { + return '$count 件の関連メッセージ'; + } + + @override + String get rememberMe => 'ログイン情報を記憶する'; + + @override + String get removeAttachment => '添付ファイルを削除'; + + @override + String get requestNotification => '通知をリクエスト'; + + @override + String get reset => 'リセット'; + + @override + String get restore => '復元'; + + @override + String get rotateAndFlip => '回転 & 反転'; + + @override + String get rotateLeft => '左に回転'; + + @override + String get rotateRight => '右に回転'; + + @override + String get search => '検索'; + + @override + String get searchStickers => 'ステッカーを検索'; + + @override + String get selectProfileImage => '画像を選択'; + + @override + String get selectedContacts => '選択した連絡先'; + + @override + String get send => '送信'; + + @override + String get sendMessage => 'メッセージを送信'; + + @override + String get settings => '設定'; + + @override + String get shortcuts => 'ショートカット'; + + @override + String get status => 'ステータス'; + + @override + String get sticker => 'ステッカー'; + + @override + String get systemLanguage => 'システム'; + + @override + String get systemTheme => 'システム'; + + @override + String get theme => 'テーマ'; + + @override + String get today => '今日'; + + @override + String get unpin => 'ピン留め解除'; + + @override + String get update => '更新'; + + @override + String get userId => 'ユーザーID'; + + @override + String get userPassword => 'パスワード'; + + @override + String get userPresenceAppearOffline => 'オフライン表示'; + + @override + String get userPresenceAvailable => '使用可能'; + + @override + String get userPresenceAway => '退席中'; + + @override + String get userPresenceBusy => '取り込み中'; + + @override + String get userPresenceDoNotDisturb => '応答不可'; + + @override + String get version => 'バージョン'; + + @override + String get video => 'ビデオ'; + + @override + String get videoNotFound => 'ビデオが見つかりません'; + + @override + String get yesterday => '昨日'; + + @override + String get youtube => 'YouTube'; +} diff --git a/turms-chat-demo-flutter/lib/ui/l10n/app_localizations_zh.dart b/turms-chat-demo-flutter/lib/ui/l10n/app_localizations_zh.dart new file mode 100644 index 0000000000..5e9f1fd762 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/l10n/app_localizations_zh.dart @@ -0,0 +1,404 @@ +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get about => '关于'; + + @override + String get accept => '接受'; + + @override + String get actionOnClose => '关闭时的动作'; + + @override + String get actions => '操作'; + + @override + String get addContact => '添加联系人'; + + @override + String get addNewMember => '添加新成员'; + + @override + String get addNewRelationship => '添加新关系'; + + @override + String get alreadyLatestVersion => '这是最新版本'; + + @override + String get alwaysOnTopDisable => '取消置顶'; + + @override + String get alwaysOnTopEnable => '置顶'; + + @override + String get appearance => '外观'; + + @override + String get audio => '音频'; + + @override + String get autoLogin => '自动登录'; + + @override + String get brightness => '亮度'; + + @override + String get cancel => '取消'; + + @override + String get changingStatusToAwayWhenInactiveForMinutes => + '自动将状态设置为离开,当处于非活动状态超过%%分钟'; + + @override + String get chatHistory => '聊天记录'; + + @override + String get chatInfo => '聊天信息'; + + @override + String get chats => '聊天'; + + @override + String get checkForUpdatesAutomatically => '自动检查更新'; + + @override + String get clearChatHistory => '清除聊天记录'; + + @override + String get close => '关闭'; + + @override + String get confirm => '确定'; + + @override + String get contacts => '联系人'; + + @override + String get create => '创建'; + + @override + String get createGroup => '创建群组'; + + @override + String get darkTheme => '暗主题'; + + @override + String get delete => '删除'; + + @override + String get deleteChat => '删除聊天记录'; + + @override + String get disableNewMessageNotification => '禁用新消息通知'; + + @override + String get downloadCancel => '取消下载'; + + @override + String get downloadPause => '暂停下载'; + + @override + String get downloadStart => '开始下载'; + + @override + String get draft => '草稿'; + + @override + String get dropFilesHere => '在此放置文件'; + + @override + String get edit => '编辑'; + + @override + String get editProfileImage => '编辑头像'; + + @override + String get enableNewMessageNotification => '启用新消息通知'; + + @override + String get error => '错误'; + + @override + String get exit => '退出'; + + @override + String get failedToDownload => '下载失败'; + + @override + String failedToDownloadFileTooLarge(Object size) { + return '文件大于${size}MB,无法下载'; + } + + @override + String get failedToSendImageInvalidUrl => '发送图片失败: 无效图片URL'; + + @override + String failedToUpdateSettings(Object error) { + return '设置更新失败: $error'; + } + + @override + String get file => '文件'; + + @override + String get fileName => '文件名'; + + @override + String get fileSize => '大小'; + + @override + String get fileTransfer => '文件传输助手'; + + @override + String get fileType => '类型'; + + @override + String get fileUploadDate => '上传时间'; + + @override + String get fileUploader => '上传者'; + + @override + String get files => '文件'; + + @override + String get flipHorizontally => '水平翻转'; + + @override + String get flipVertically => '垂直翻转'; + + @override + String get friendRequests => '好友请求'; + + @override + String get goToChatPage => '切换至聊天页面'; + + @override + String get goToContactsPage => '切换至联系人页面'; + + @override + String get goToFilesPage => '切换至文件页面'; + + @override + String get groupId => '群组ID'; + + @override + String get groupMembershipRequests => '入群请求'; + + @override + String get groups => '群组'; + + @override + String get image => '图片'; + + @override + String get joinGroup => '加入群组'; + + @override + String get language => '语言'; + + @override + String get launchAndExit => '启动与退出'; + + @override + String get launchOnStartup => '启动时运行'; + + @override + String get leaveGroup => '离开群组'; + + @override + String get lightTheme => '亮主题'; + + @override + String get loading => '加载中'; + + @override + String get logOut => '退出登陆'; + + @override + String get login => '登陆'; + + @override + String get lowDiskSpace => '存储空间不足'; + + @override + String lowDiskSpacePrompt(Object space) { + return '存储空间低于${space}MB。请删除一些文件或应用以释放空间。'; + } + + @override + String get maximize => '最大化'; + + @override + String get message => '消息'; + + @override + String get messages => '发送消息'; + + @override + String get minimize => '最小化'; + + @override + String get minimizeToTray => '最小化到托盘'; + + @override + String get muteNotifications => '消息免打扰'; + + @override + String get network => '网络'; + + @override + String get newMessageNotification => '新消息通知'; + + @override + String get noMatchingGroupMembersFound => '未找到匹配的成员'; + + @override + String get noResultsFound => '没有找到结果'; + + @override + String get none => '无'; + + @override + String get notifications => '通知'; + + @override + String get openAboutDialog => '打开关于对话框'; + + @override + String get openFolder => '打开文件夹'; + + @override + String get openSettingsDialog => '打开设置对话框'; + + @override + String get pin => '置顶'; + + @override + String get pleaseEnterPassword => '请输入密码'; + + @override + String get pleaseEnterUserId => '请输入用户ID'; + + @override + String get progress => '进度'; + + @override + String relatedMessages(Object count) { + return '$count条相关消息'; + } + + @override + String get rememberMe => '记住我'; + + @override + String get removeAttachment => '删除附件'; + + @override + String get requestNotification => '请求通知'; + + @override + String get reset => '重置'; + + @override + String get restore => '恢复'; + + @override + String get rotateAndFlip => '旋转 & 翻转'; + + @override + String get rotateLeft => '向左旋转'; + + @override + String get rotateRight => '向右旋转'; + + @override + String get search => '搜索'; + + @override + String get searchStickers => '搜索表情'; + + @override + String get selectProfileImage => '选择头像'; + + @override + String get selectedContacts => '已选中的联系人'; + + @override + String get send => '发送'; + + @override + String get sendMessage => '发送消息'; + + @override + String get settings => '设置'; + + @override + String get shortcuts => '快捷键'; + + @override + String get status => '状态'; + + @override + String get sticker => '表情'; + + @override + String get systemLanguage => '系统语言'; + + @override + String get systemTheme => '系统主题'; + + @override + String get theme => '主题'; + + @override + String get today => '今天'; + + @override + String get unpin => '取消置顶'; + + @override + String get update => '更新'; + + @override + String get userId => '用户ID'; + + @override + String get userPassword => '密码'; + + @override + String get userPresenceAppearOffline => '隐身'; + + @override + String get userPresenceAvailable => '在线'; + + @override + String get userPresenceAway => '离开'; + + @override + String get userPresenceBusy => '忙碌'; + + @override + String get userPresenceDoNotDisturb => '勿扰'; + + @override + String get version => '版本'; + + @override + String get video => '视频'; + + @override + String get videoNotFound => '视频不存在'; + + @override + String get yesterday => '昨天'; + + @override + String get youtube => 'YouTube'; +} diff --git a/turms-chat-demo-flutter/lib/ui/l10n/arb/app_en.arb b/turms-chat-demo-flutter/lib/ui/l10n/arb/app_en.arb new file mode 100644 index 0000000000..7c056be324 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/l10n/arb/app_en.arb @@ -0,0 +1,131 @@ +{ + "about": "About", + "accept": "Accept", + "actionOnClose": "Action on Close", + "actions": "Actions", + "addContact": "Add Contact", + "addNewMember": "Add New Member", + "addNewRelationship": "Add New Relationship", + "alreadyLatestVersion": "This is the latest version", + "alwaysOnTopDisable": "Disable Always on Top", + "alwaysOnTopEnable": "Always on Top", + "appearance": "Appearance", + "audio": "Audio", + "autoLogin": "Auto Login", + "brightness": "Brightness", + "cancel": "Cancel", + "changingStatusToAwayWhenInactiveForMinutes": "Changing status to \"Away\" when inactive for %% minutes", + "chatHistory": "Chat History", + "chatInfo": "Chat Info", + "chats": "Chats", + "checkForUpdatesAutomatically": "Check for Updates Automatically", + "clearChatHistory": "Clear Chat History", + "close": "Close", + "confirm": "Confirm", + "contacts": "Contacts", + "create": "Create", + "createGroup": "Create Group", + "darkTheme": "Dark", + "delete": "Delete", + "deleteChat": "Delete Chat", + "disableNewMessageNotification": "Mute", + "downloadCancel": "Cancel", + "downloadPause": "Pause", + "downloadStart": "Start", + "draft": "Draft", + "dropFilesHere": "Drop files here", + "edit": "Edit", + "editProfileImage": "Edit Profile Image", + "enableNewMessageNotification": "Unmute", + "error": "Error", + "exit": "Exit", + "failedToDownload": "Failed to download", + "failedToDownloadFileTooLarge": "Failed to download file: File is larger than {size}MB", + "failedToSendImageInvalidUrl": "Failed to send image: Invalid URL", + "failedToUpdateSettings": "Failed to update settings: {error}", + "file": "File", + "fileName": "Name", + "fileSize": "Size", + "fileTransfer": "File Transfer", + "fileType": "Type", + "fileUploadDate": "Upload Date", + "fileUploader": "Uploader", + "files": "Files", + "flipHorizontally": "Flip Horizontally", + "flipVertically": "Flip Vertically", + "friendRequests": "Friend Requests", + "goToChatPage": "Go to Chat Page", + "goToContactsPage": "Go to Contacts Page", + "goToFilesPage": "Go to Files Page", + "groupId": "Group ID", + "groupMembershipRequests": "Group Membership Requests", + "groups": "Groups", + "image": "Image", + "joinGroup": "Join Group", + "language": "Language", + "launchAndExit": "Launch and Exit", + "launchOnStartup": "Run on Startup", + "leaveGroup": "Leave Group", + "lightTheme": "Light", + "loading": "Loading", + "logOut": "Log Out", + "login": "Login", + "lowDiskSpace": "Low Disk Space", + "lowDiskSpacePrompt": "The disk space is lower than {space}MB. Please delete some files or applications to free up space.", + "maximize": "Maximize", + "message": "Message", + "messages": "Messages", + "minimize": "Minimize", + "minimizeToTray": "Minimize to Tray", + "muteNotifications": "Mute Notifications", + "network": "Network", + "newMessageNotification": "New Message Notification", + "noMatchingGroupMembersFound": "No matching members found", + "noResultsFound": "No results found", + "none": "None", + "notifications": "Notifications", + "openAboutDialog": "Open About Dialog", + "openFolder": "Open Folder", + "openSettingsDialog": "Open Settings Dialog", + "pin": "Pin", + "pleaseEnterPassword": "Please enter password", + "pleaseEnterUserId": "Please enter user ID", + "progress": "Progress", + "relatedMessages": "{count} related messages", + "rememberMe": "Remember Me", + "removeAttachment": "Remove Attachment", + "requestNotification": "Request Notification", + "reset": "Reset", + "restore": "Restore", + "rotateAndFlip": "Rotate & Flip", + "rotateLeft": "Rotate Left", + "rotateRight": "Rotate Right", + "search": "Search", + "searchStickers": "Search Stickers", + "selectProfileImage": "Select Image", + "selectedContacts": "Selected Contacts", + "send": "Send", + "sendMessage": "Send Message", + "settings": "Settings", + "shortcuts": "Shortcuts", + "status": "Status", + "sticker": "Sticker", + "systemLanguage": "System", + "systemTheme": "System", + "theme": "Theme", + "today": "Today", + "unpin": "Unpin", + "update": "Update", + "userId": "User ID", + "userPassword": "Password", + "userPresenceAppearOffline": "Appear Offline", + "userPresenceAvailable": "Available", + "userPresenceAway": "Away", + "userPresenceBusy": "Busy", + "userPresenceDoNotDisturb": "Do Not Disturb", + "version": "Version", + "video": "Video", + "videoNotFound": "Video not found", + "yesterday": "Yesterday", + "youtube": "YouTube" +} \ No newline at end of file diff --git a/turms-chat-demo-flutter/lib/ui/l10n/arb/app_ja.arb b/turms-chat-demo-flutter/lib/ui/l10n/arb/app_ja.arb new file mode 100644 index 0000000000..7d4f4746fc --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/l10n/arb/app_ja.arb @@ -0,0 +1,131 @@ +{ + "about": "概要", + "accept": "受け入れる", + "actionOnClose": "閉じるときのアクション", + "actions": "アクション", + "addContact": "連絡先を追加", + "addNewMember": "新しいメンバーを追加", + "addNewRelationship": "新しい関係を追加", + "alreadyLatestVersion": "これが最新バージョンです", + "alwaysOnTopDisable": "常に最前面を無効にする", + "alwaysOnTopEnable": "常に最前面", + "appearance": "外観", + "audio": "オーディオ", + "autoLogin": "自動ログイン", + "brightness": "明るさ", + "cancel": "キャンセル", + "changingStatusToAwayWhenInactiveForMinutes": "%% 分間非アクティブのときに「離席」にステータスを変更", + "chatHistory": "チャット履歴", + "chatInfo": "チャット情報", + "chats": "チャット", + "checkForUpdatesAutomatically": "自動的に更新を確認", + "clearChatHistory": "チャット履歴をクリア", + "close": "閉じる", + "confirm": "確認", + "contacts": "連絡先", + "create": "作成", + "createGroup": "グループを作成", + "darkTheme": "ダーク", + "delete": "削除", + "deleteChat": "チャットを削除", + "disableNewMessageNotification": "ミュート", + "downloadCancel": "キャンセル", + "downloadPause": "一時停止", + "downloadStart": "開始", + "draft": "下書き", + "dropFilesHere": "ここにファイルをドロップ", + "edit": "編集", + "editProfileImage": "プロフィール画像を編集", + "enableNewMessageNotification": "ミュート解除", + "error": "エラー", + "exit": "終了", + "failedToDownload": "ダウンロードに失敗しました", + "failedToDownloadFileTooLarge": "ダウンロードに失敗しました: ファイルが {size}MB より大きい", + "failedToSendImageInvalidUrl": "画像の送信に失敗しました: 無効なURL", + "failedToUpdateSettings": "設定の更新に失敗しました: {error}", + "file": "ファイル", + "fileName": "名前", + "fileSize": "サイズ", + "fileTransfer": "ファイル転送", + "fileType": "タイプ", + "fileUploadDate": "アップロード日", + "fileUploader": "アップローダー", + "files": "ファイル", + "flipHorizontally": "水平方向に反転", + "flipVertically": "垂直方向に反転", + "friendRequests": "友達リクエスト", + "goToChatPage": "チャットページに移動", + "goToContactsPage": "連絡先ページに移動", + "goToFilesPage": "ファイルページに移動", + "groupId": "グループID", + "groupMembershipRequests": "グループメンバーシップリクエスト", + "groups": "グループ", + "image": "画像", + "joinGroup": "グループに参加", + "language": "言語", + "launchAndExit": "起動と終了", + "launchOnStartup": "起動時に実行", + "leaveGroup": "グループを離れる", + "lightTheme": "ライト", + "loading": "読み込み中", + "logOut": "ログアウト", + "login": "ログイン", + "lowDiskSpace": "ディスクスペースが不足しています", + "lowDiskSpacePrompt": "{space}MB よりもディスクスペースが少なくなっています。スペースを空けるためにいくつかのファイルやアプリケーションを削除してください。", + "maximize": "最大化", + "message": "メッセージ", + "messages": "メッセージ", + "minimize": "最小化", + "minimizeToTray": "トレイに最小化", + "muteNotifications": "通知をミュート", + "network": "ネットワーク", + "newMessageNotification": "新しいメッセージ通知", + "noMatchingGroupMembersFound": "一致するメンバーが見つかりませんでした", + "noResultsFound": "結果が見つかりませんでした", + "none": "なし", + "notifications": "通知", + "openAboutDialog": "概要ダイアログを開く", + "openFolder": "フォルダーを開く", + "openSettingsDialog": "設定ダイアログを開く", + "pin": "ピン留め", + "pleaseEnterPassword": "パスワードを入力してください", + "pleaseEnterUserId": "ユーザーIDを入力してください", + "progress": "進行状況", + "relatedMessages": "{count} 件の関連メッセージ", + "rememberMe": "ログイン情報を記憶する", + "removeAttachment": "添付ファイルを削除", + "requestNotification": "通知をリクエスト", + "reset": "リセット", + "restore": "復元", + "rotateAndFlip": "回転 & 反転", + "rotateLeft": "左に回転", + "rotateRight": "右に回転", + "search": "検索", + "searchStickers": "ステッカーを検索", + "selectProfileImage": "画像を選択", + "selectedContacts": "選択した連絡先", + "send": "送信", + "sendMessage": "メッセージを送信", + "settings": "設定", + "shortcuts": "ショートカット", + "status": "ステータス", + "sticker": "ステッカー", + "systemLanguage": "システム", + "systemTheme": "システム", + "theme": "テーマ", + "today": "今日", + "unpin": "ピン留め解除", + "update": "更新", + "userId": "ユーザーID", + "userPassword": "パスワード", + "userPresenceAppearOffline": "オフライン表示", + "userPresenceAvailable": "使用可能", + "userPresenceAway": "退席中", + "userPresenceBusy": "取り込み中", + "userPresenceDoNotDisturb": "応答不可", + "version": "バージョン", + "video": "ビデオ", + "videoNotFound": "ビデオが見つかりません", + "yesterday": "昨日", + "youtube": "YouTube" +} \ No newline at end of file diff --git a/turms-chat-demo-flutter/lib/ui/l10n/arb/app_zh.arb b/turms-chat-demo-flutter/lib/ui/l10n/arb/app_zh.arb new file mode 100644 index 0000000000..24e5767238 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/l10n/arb/app_zh.arb @@ -0,0 +1,131 @@ +{ + "about": "关于", + "accept": "接受", + "actionOnClose": "关闭时的动作", + "actions": "操作", + "addContact": "添加联系人", + "addNewMember": "添加新成员", + "addNewRelationship": "添加新关系", + "alreadyLatestVersion": "这是最新版本", + "alwaysOnTopDisable": "取消置顶", + "alwaysOnTopEnable": "置顶", + "appearance": "外观", + "audio": "音频", + "autoLogin": "自动登录", + "brightness": "亮度", + "cancel": "取消", + "changingStatusToAwayWhenInactiveForMinutes": "自动将状态设置为离开,当处于非活动状态超过%%分钟", + "chatHistory": "聊天记录", + "chatInfo": "聊天信息", + "chats": "聊天", + "checkForUpdatesAutomatically": "自动检查更新", + "clearChatHistory": "清除聊天记录", + "close": "关闭", + "confirm": "确定", + "contacts": "联系人", + "create": "创建", + "createGroup": "创建群组", + "darkTheme": "暗主题", + "delete": "删除", + "deleteChat": "删除聊天记录", + "disableNewMessageNotification": "禁用新消息通知", + "downloadCancel": "取消下载", + "downloadPause": "暂停下载", + "downloadStart": "开始下载", + "draft": "草稿", + "dropFilesHere": "在此放置文件", + "edit": "编辑", + "editProfileImage": "编辑头像", + "enableNewMessageNotification": "启用新消息通知", + "error": "错误", + "exit": "退出", + "failedToDownload": "下载失败", + "failedToDownloadFileTooLarge": "文件大于{size}MB,无法下载", + "failedToSendImageInvalidUrl": "发送图片失败: 无效图片URL", + "failedToUpdateSettings": "设置更新失败: {error}", + "file": "文件", + "fileName": "文件名", + "fileSize": "大小", + "fileTransfer": "文件传输助手", + "fileType": "类型", + "fileUploadDate": "上传时间", + "fileUploader": "上传者", + "files": "文件", + "flipHorizontally": "水平翻转", + "flipVertically": "垂直翻转", + "friendRequests": "好友请求", + "goToChatPage": "切换至聊天页面", + "goToContactsPage": "切换至联系人页面", + "goToFilesPage": "切换至文件页面", + "groupId": "群组ID", + "groupMembershipRequests": "入群请求", + "groups": "群组", + "image": "图片", + "joinGroup": "加入群组", + "language": "语言", + "launchAndExit": "启动与退出", + "launchOnStartup": "启动时运行", + "leaveGroup": "离开群组", + "lightTheme": "亮主题", + "loading": "加载中", + "logOut": "退出登陆", + "login": "登陆", + "lowDiskSpace": "存储空间不足", + "lowDiskSpacePrompt": "存储空间低于{space}MB。请删除一些文件或应用以释放空间。", + "maximize": "最大化", + "message": "消息", + "messages": "发送消息", + "minimize": "最小化", + "minimizeToTray": "最小化到托盘", + "muteNotifications": "消息免打扰", + "network": "网络", + "newMessageNotification": "新消息通知", + "noMatchingGroupMembersFound": "未找到匹配的成员", + "noResultsFound": "没有找到结果", + "none": "无", + "notifications": "通知", + "openAboutDialog": "打开关于对话框", + "openFolder": "打开文件夹", + "openSettingsDialog": "打开设置对话框", + "pin": "置顶", + "pleaseEnterPassword": "请输入密码", + "pleaseEnterUserId": "请输入用户ID", + "progress": "进度", + "relatedMessages": "{count}条相关消息", + "rememberMe": "记住我", + "removeAttachment": "删除附件", + "requestNotification": "请求通知", + "reset": "重置", + "restore": "恢复", + "rotateAndFlip": "旋转 & 翻转", + "rotateLeft": "向左旋转", + "rotateRight": "向右旋转", + "search": "搜索", + "searchStickers": "搜索表情", + "selectProfileImage": "选择头像", + "selectedContacts": "已选中的联系人", + "send": "发送", + "sendMessage": "发送消息", + "settings": "设置", + "shortcuts": "快捷键", + "status": "状态", + "sticker": "表情", + "systemLanguage": "系统语言", + "systemTheme": "系统主题", + "theme": "主题", + "today": "今天", + "unpin": "取消置顶", + "update": "更新", + "userId": "用户ID", + "userPassword": "密码", + "userPresenceAppearOffline": "隐身", + "userPresenceAvailable": "在线", + "userPresenceAway": "离开", + "userPresenceBusy": "忙碌", + "userPresenceDoNotDisturb": "勿扰", + "version": "版本", + "video": "视频", + "videoNotFound": "视频不存在", + "yesterday": "昨天", + "youtube": "YouTube" +} \ No newline at end of file diff --git a/turms-chat-demo-flutter/lib/ui/l10n/locale_utils.dart b/turms-chat-demo-flutter/lib/ui/l10n/locale_utils.dart new file mode 100644 index 0000000000..a4823a099a --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/l10n/locale_utils.dart @@ -0,0 +1,18 @@ +import 'dart:ui'; + +class LocaleUtils { + LocaleUtils._(); + + static Locale fromLanguageTag(String languageTag) { + final subtags = languageTag.split('-'); + return switch (subtags.length) { + 1 => Locale(subtags[0]), + 2 => Locale(subtags[0], subtags[1]), + 3 => Locale.fromSubtags( + languageCode: subtags[0], + scriptCode: subtags[1], + countryCode: subtags[2]), + _ => throw ArgumentError(), + }; + } +} diff --git a/turms-chat-demo-flutter/lib/ui/l10n/view_models/app_localizations_view_model.dart b/turms-chat-demo-flutter/lib/ui/l10n/view_models/app_localizations_view_model.dart new file mode 100644 index 0000000000..032963ab18 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/l10n/view_models/app_localizations_view_model.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../app_localizations.dart'; +import 'system_locale_info_view_model.dart'; + +final appLocalizationsViewModel = StateProvider((ref) { + final localeInfo = ref.watch(localeInfoViewModel); + return lookupAppLocalizations(localeInfo.locale); +}); diff --git a/turms-chat-demo-flutter/lib/ui/l10n/view_models/date_format_view_models.dart b/turms-chat-demo-flutter/lib/ui/l10n/view_models/date_format_view_models.dart new file mode 100644 index 0000000000..c3ae1838f5 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/l10n/view_models/date_format_view_models.dart @@ -0,0 +1,28 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import 'app_localizations_view_model.dart'; + +final dateFormatViewModel_yMd = StateProvider( + (ref) => DateFormat.yMd(ref.watch(appLocalizationsViewModel).localeName)); + +final dateFormatViewModel_yMdHm = StateProvider((ref) => + DateFormat.yMd(ref.watch(appLocalizationsViewModel).localeName).add_Hm()); + +final dateFormatViewModel_yMdjm = StateProvider((ref) => + DateFormat.yMd(ref.watch(appLocalizationsViewModel).localeName).add_jm()); + +final dateFormatViewModel_yMdjms = StateProvider((ref) => + DateFormat.yMd(ref.watch(appLocalizationsViewModel).localeName).add_jms()); + +final dateFormatViewModel_Md = StateProvider( + (ref) => DateFormat.Md(ref.watch(appLocalizationsViewModel).localeName)); + +final dateFormatViewModel_Mdjm = StateProvider((ref) => + DateFormat.Md(ref.watch(appLocalizationsViewModel).localeName).add_jm()); + +final dateFormatViewModel_Mdjms = StateProvider((ref) => + DateFormat.Md(ref.watch(appLocalizationsViewModel).localeName).add_jms()); + +final dateFormatViewModel_jm = StateProvider( + (ref) => DateFormat.jm(ref.watch(appLocalizationsViewModel).localeName)); diff --git a/turms-chat-demo-flutter/lib/ui/l10n/view_models/index.dart b/turms-chat-demo-flutter/lib/ui/l10n/view_models/index.dart new file mode 100644 index 0000000000..6a5623ce06 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/l10n/view_models/index.dart @@ -0,0 +1,3 @@ +export 'app_localizations_view_model.dart'; +export 'date_format_view_models.dart'; +export 'system_locale_info_view_model.dart'; diff --git a/turms-chat-demo-flutter/lib/ui/l10n/view_models/system_locale_info_view_model.dart b/turms-chat-demo-flutter/lib/ui/l10n/view_models/system_locale_info_view_model.dart new file mode 100644 index 0000000000..5dcf69c2a6 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/l10n/view_models/system_locale_info_view_model.dart @@ -0,0 +1,74 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../domain/user/view_models/user_settings_view_model.dart'; +import '../app_localizations.dart'; + +class LocaleInfo { + LocaleInfo({required this.isSystemLocale, required this.locale}); + + final bool isSystemLocale; + final Locale locale; +} + +class LocaleInfoViewModelNotifier extends Notifier { + @override + LocaleInfo build() { + final observer = _LocaleObserver(ref); + WidgetsBinding.instance.addObserver(observer); + ref.onDispose(() => WidgetsBinding.instance.removeObserver(observer)); + + final locale = ref.watch(userSettingsViewModel)?.locale; + if (locale == null) { + return _getDefaultLocaleInfo(); + } + return AppLocalizations.delegate.isSupported(locale) + ? LocaleInfo(isSystemLocale: false, locale: locale) + : LocaleInfo(isSystemLocale: true, locale: _localeEn); + } + + LocaleInfo useSystemLocale() { + final localeInfo = _getDefaultLocaleInfo(); + ref.read(localeInfoViewModel.notifier).state = localeInfo; + return localeInfo; + } + + LocaleInfo? updateLocaleIfSupported(String name) { + final locale = Locale(name); + if (!AppLocalizations.delegate.isSupported(locale)) { + return null; + } + final localeInfo = LocaleInfo(isSystemLocale: false, locale: locale); + ref.read(localeInfoViewModel.notifier).state = localeInfo; + return localeInfo; + } +} + +final localeInfoViewModel = + NotifierProvider( + LocaleInfoViewModelNotifier.new); + +const _localeEn = Locale('en'); + +class _LocaleObserver extends WidgetsBindingObserver { + _LocaleObserver(this.ref); + + final Ref ref; + + @override + void didChangeLocales(List? locales) { + final locale = ref.watch(userSettingsViewModel)?.locale; + if (locale == null) { + ref.read(localeInfoViewModel.notifier).state = _getDefaultLocaleInfo(); + } + } +} + +LocaleInfo _getDefaultLocaleInfo() { + final systemLocale = WidgetsBinding.instance.platformDispatcher.locale; + return LocaleInfo( + isSystemLocale: true, + locale: AppLocalizations.delegate.isSupported(systemLocale) + ? systemLocale + : _localeEn); +} diff --git a/turms-chat-demo-flutter/lib/ui/themes/app_theme_extension.dart b/turms-chat-demo-flutter/lib/ui/themes/app_theme_extension.dart new file mode 100644 index 0000000000..477fc9d442 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/themes/app_theme_extension.dart @@ -0,0 +1,524 @@ +import 'package:flutter/material.dart'; + +import 'colors.dart'; +import 'fonts.dart'; +import 'sizes.dart'; +import 'styles.dart'; + +// TODO: we need to provide different font styles for different fonts +// as even using the same font properties, the text may display quite different. +class AppThemeExtension extends ThemeExtension { + const AppThemeExtension({ + required this.themeMode, + required this.successColor, + required this.warningColor, + required this.errorColor, + required this.infoColor, + required this.dangerColor, + required this.dangerTextStyle, + required this.highlightTextStyle, + required this.maskColor, + required this.avatarIconColor, + required this.avatarBackgroundColor, + required this.checkboxColor, + required this.checkboxTextStyle, + required this.iconButtonContainerHoveredColor, + required this.iconButtonContainerPressedColor, + required this.menuDecoration, + required this.menuItemColor, + required this.menuItemHoveredColor, + required this.menuItemTextStyle, + required this.popupDecoration, + required this.tabTextStyle, + required this.toastDecoration, + required this.homePageBackgroundColor, + required this.mainNavigationRailBackgroundColor, + required this.mainNavigationRailIconColor, + required this.subNavigationRailSearchBarBackgroundColor, + required this.subNavigationRailLoadingIndicatorBackgroundColor, + required this.subNavigationRailDividerColor, + required this.chatSessionPaneDividerColor, + required this.chatSessionDetailsDrawerBackgroundColor, + required this.chatSessionMessageTextStyle, + required this.chatSessionMessageEmojiTextStyle, + required this.tileBackgroundColor, + required this.tileBackgroundHighlightedColor, + required this.tileBackgroundHoveredColor, + required this.tileBackgroundFocusedColor, + required this.conversationTileMessageTextStyle, + required this.conversationTileHighlightedTextStyle, + required this.conversationTileTimestampTextStyle, + required this.messageAttachmentColor, + required this.messageAttachmentHoveredColor, + required this.messageBubbleErrorIconBackgroundColor, + required this.messageBubbleErrorIconColor, + required this.fileTableTitleTextStyle, + required this.fileTableCellTextStyle, + required this.settingPageSubNavigationRailDividerColor, + required this.dialogTitleTextStyleMedium, + required this.dialogTitleTextStyleLarge, + required this.descriptionTextStyle, + required this.linkTextStyle, + required this.linkHoveredTextStyle, + }); + + static final light = AppThemeExtension( + themeMode: ThemeMode.light, + successColor: AppColors.green6, + warningColor: AppColors.gold6, + errorColor: Colors.red, + infoColor: AppColors.blue6, + dangerColor: Colors.red, + dangerTextStyle: const TextStyle(color: Colors.red), + highlightTextStyle: const TextStyle(color: Colors.red), + maskColor: Colors.black54, + avatarIconColor: Colors.white, + avatarBackgroundColor: const Color.fromARGB(255, 117, 117, 117), + checkboxColor: AppColors.gray6, + checkboxTextStyle: + const TextStyle(color: Color(0xA6000000), fontSize: 16), + // hsl(0, 0%, 91%) + iconButtonContainerHoveredColor: const Color.fromARGB(255, 231, 231, 231), + // hsl(0, 0%, 85%) + iconButtonContainerPressedColor: const Color.fromARGB(255, 218, 218, 218), + menuDecoration: BoxDecoration( + color: Colors.white, + borderRadius: Sizes.borderRadiusCircular2, + border: Border.all(color: Colors.grey.shade400), + // border: Border.all(color: AppColors.gray5), + boxShadow: Styles.boxShadow, + ), + menuItemTextStyle: const TextStyle( + fontSize: 12, + ), + menuItemColor: Colors.white, + menuItemHoveredColor: Colors.grey.shade300, + popupDecoration: const BoxDecoration( + color: Colors.white, + borderRadius: Sizes.borderRadiusCircular4, + boxShadow: Styles.boxShadow), + tabTextStyle: const TextStyle(color: Color.fromARGB(255, 89, 89, 89)), + toastDecoration: const BoxDecoration( + color: Colors.white, + borderRadius: Sizes.borderRadiusCircular8, + boxShadow: Styles.boxShadow, + ), + // hsl(0, 0%, 95%) + homePageBackgroundColor: const Color.fromARGB(255, 243, 243, 243), + // hsl(0, 0%, 93%) + mainNavigationRailBackgroundColor: + const Color.fromARGB(255, 237, 237, 237), + // hsl(0, 0%, 42%) + mainNavigationRailIconColor: const Color.fromARGB(255, 107, 107, 107), + // hsl(0, 0%, 95%) + subNavigationRailSearchBarBackgroundColor: + const Color.fromARGB(255, 247, 247, 247), + subNavigationRailLoadingIndicatorBackgroundColor: + const Color.fromARGB(255, 237, 237, 237), + subNavigationRailDividerColor: const Color.fromARGB(255, 213, 213, 213), + chatSessionPaneDividerColor: const Color.fromARGB(255, 231, 231, 231), + chatSessionDetailsDrawerBackgroundColor: Colors.white, + chatSessionMessageTextStyle: const TextStyle( + fontSize: 14, + ), + chatSessionMessageEmojiTextStyle: TextStyle( + fontSize: 20, + fontFamily: Fonts.emojiFontFamily, + fontFamilyFallback: Fonts.emojiFontFamilyFallback), + // hsl(0, 0%, 97%) + tileBackgroundColor: const Color.fromARGB(255, 247, 247, 247), + tileBackgroundHighlightedColor: const Color.fromARGB(255, 210, 210, 210), + // hsl(0, 0%, 92%) + tileBackgroundHoveredColor: const Color.fromARGB(255, 234, 234, 234), + // hsl(0, 0%, 87%) + tileBackgroundFocusedColor: const Color.fromARGB(255, 222, 222, 222), + conversationTileMessageTextStyle: const TextStyle( + color: AppColors.gray7, + fontSize: 12, + ), + conversationTileHighlightedTextStyle: + TextStyle(backgroundColor: AppColors.primary.withValues(alpha: 0.3)), + conversationTileTimestampTextStyle: + const TextStyle(color: AppColors.gray7, fontSize: 12), + messageAttachmentColor: const Color.fromARGB(255, 250, 250, 250), + messageAttachmentHoveredColor: Colors.white, + messageBubbleErrorIconBackgroundColor: + const Color.fromARGB(255, 250, 81, 81), + messageBubbleErrorIconColor: Colors.white, + fileTableTitleTextStyle: + const TextStyle(color: Color.fromARGB(255, 51, 51, 51)), + fileTableCellTextStyle: + const TextStyle(color: Color.fromARGB(255, 102, 102, 102)), + settingPageSubNavigationRailDividerColor: + const Color.fromARGB(255, 240, 240, 240), + dialogTitleTextStyleMedium: + TextStyle(fontSize: 14, color: Colors.grey.shade600), + dialogTitleTextStyleLarge: + TextStyle(fontSize: 16, color: Colors.grey.shade600), + descriptionTextStyle: const TextStyle( + // TODO: Or Color(0xA6000000)? + color: Colors.grey, + ), + linkTextStyle: const TextStyle( + color: AppColors.blue5, + ), + linkHoveredTextStyle: const TextStyle( + color: AppColors.blue6, + )); + + // TODO + static final dark = light; + + final ThemeMode themeMode; + + // Semantic colors/styles + final Color successColor; + final Color warningColor; + final Color errorColor; + final Color infoColor; + + final Color dangerColor; + final TextStyle dangerTextStyle; + final TextStyle highlightTextStyle; + + // Background colors + final Color maskColor; + + // Component colors/styles + final Color avatarIconColor; + final Color avatarBackgroundColor; + + final Color checkboxColor; + final TextStyle checkboxTextStyle; + + final Color iconButtonContainerHoveredColor; + final Color iconButtonContainerPressedColor; + + final BoxDecoration menuDecoration; + final Color menuItemColor; + final Color menuItemHoveredColor; + final TextStyle menuItemTextStyle; + + final BoxDecoration popupDecoration; + + final TextStyle tabTextStyle; + + final BoxDecoration toastDecoration; + + // Page colors/styles + final Color homePageBackgroundColor; + final Color mainNavigationRailBackgroundColor; + final Color mainNavigationRailIconColor; + final Color subNavigationRailSearchBarBackgroundColor; + final Color subNavigationRailLoadingIndicatorBackgroundColor; + final Color subNavigationRailDividerColor; + + final Color chatSessionPaneDividerColor; + final Color chatSessionDetailsDrawerBackgroundColor; + final TextStyle chatSessionMessageTextStyle; + final TextStyle chatSessionMessageEmojiTextStyle; + + final Color tileBackgroundColor; + final Color tileBackgroundHighlightedColor; + final Color tileBackgroundHoveredColor; + final Color tileBackgroundFocusedColor; + + final TextStyle conversationTileMessageTextStyle; + final TextStyle conversationTileHighlightedTextStyle; + final TextStyle conversationTileTimestampTextStyle; + + final Color messageAttachmentColor; + final Color messageAttachmentHoveredColor; + final Color messageBubbleErrorIconBackgroundColor; + final Color messageBubbleErrorIconColor; + + final TextStyle fileTableTitleTextStyle; + final TextStyle fileTableCellTextStyle; + + final Color settingPageSubNavigationRailDividerColor; + final TextStyle dialogTitleTextStyleMedium; + final TextStyle dialogTitleTextStyleLarge; + + // Common text styles + final TextStyle descriptionTextStyle; + final TextStyle linkTextStyle; + final TextStyle linkHoveredTextStyle; + + @override + AppThemeExtension copyWith({ + ThemeMode? themeMode, + Color? successColor, + Color? warningColor, + Color? errorColor, + Color? infoColor, + Color? dangerColor, + TextStyle? dangerTextStyle, + TextStyle? highlightTextStyle, + Color? maskColor, + Color? avatarIconColor, + Color? avatarBackgroundColor, + Color? checkboxColor, + TextStyle? checkboxTextStyle, + Color? iconButtonContainerHoveredColor, + Color? iconButtonContainerPressedColor, + BoxDecoration? menuDecoration, + Color? menuItemColor, + Color? menuItemHoveredColor, + TextStyle? menuItemTextStyle, + BoxDecoration? popupDecoration, + TextStyle? tabTextStyle, + BoxDecoration? toastDecoration, + Color? homePageBackgroundColor, + Color? mainNavigationRailBackgroundColor, + Color? mainNavigationRailIconColor, + Color? subNavigationRailSearchBarBackgroundColor, + Color? subNavigationRailLoadingIndicatorBackgroundColor, + Color? subNavigationRailDividerColor, + Color? chatSessionPaneDividerColor, + Color? chatSessionDetailsDrawerBackgroundColor, + TextStyle? chatSessionMessageEditorTextStyle, + TextStyle? chatSessionMessageEditorEmojiTextStyle, + Color? tileBackgroundColor, + Color? tileBackgroundHighlightedColor, + Color? tileBackgroundHoveredColor, + Color? tileBackgroundFocusedColor, + TextStyle? conversationTileMessageTextStyle, + TextStyle? conversationTileHighlightedTextStyle, + TextStyle? conversationTileTimestampTextStyle, + Color? messageAttachmentColor, + Color? messageAttachmentHoveredColor, + Color? messageBubbleErrorIconBackgroundColor, + Color? messageBubbleErrorIconColor, + TextStyle? fileTableTitleTextStyle, + TextStyle? fileTableCellTextStyle, + Color? settingPageSubNavigationRailDividerColor, + TextStyle? dialogTitleTextStyleMedium, + TextStyle? dialogTitleTextStyleLarge, + TextStyle? descriptionTextStyle, + TextStyle? linkTextStyle, + TextStyle? linkHoveredTextStyle, + }) => + AppThemeExtension( + themeMode: themeMode ?? this.themeMode, + successColor: successColor ?? this.successColor, + warningColor: warningColor ?? this.warningColor, + errorColor: errorColor ?? this.errorColor, + infoColor: infoColor ?? this.infoColor, + dangerColor: dangerColor ?? this.dangerColor, + dangerTextStyle: dangerTextStyle ?? this.dangerTextStyle, + highlightTextStyle: highlightTextStyle ?? this.highlightTextStyle, + maskColor: maskColor ?? this.maskColor, + avatarIconColor: avatarIconColor ?? this.avatarIconColor, + avatarBackgroundColor: + avatarBackgroundColor ?? this.avatarBackgroundColor, + checkboxColor: checkboxColor ?? this.checkboxColor, + checkboxTextStyle: checkboxTextStyle ?? this.checkboxTextStyle, + iconButtonContainerHoveredColor: iconButtonContainerHoveredColor ?? + this.iconButtonContainerHoveredColor, + iconButtonContainerPressedColor: iconButtonContainerPressedColor ?? + this.iconButtonContainerPressedColor, + menuDecoration: menuDecoration ?? this.menuDecoration, + menuItemColor: menuItemColor ?? this.menuItemColor, + menuItemHoveredColor: menuItemHoveredColor ?? this.menuItemHoveredColor, + menuItemTextStyle: menuItemTextStyle ?? this.menuItemTextStyle, + popupDecoration: popupDecoration ?? this.popupDecoration, + tabTextStyle: tabTextStyle ?? this.tabTextStyle, + toastDecoration: toastDecoration ?? this.toastDecoration, + homePageBackgroundColor: + homePageBackgroundColor ?? this.homePageBackgroundColor, + mainNavigationRailBackgroundColor: mainNavigationRailBackgroundColor ?? + this.mainNavigationRailBackgroundColor, + mainNavigationRailIconColor: + mainNavigationRailIconColor ?? this.mainNavigationRailIconColor, + subNavigationRailSearchBarBackgroundColor: + subNavigationRailSearchBarBackgroundColor ?? + this.subNavigationRailSearchBarBackgroundColor, + subNavigationRailLoadingIndicatorBackgroundColor: + subNavigationRailLoadingIndicatorBackgroundColor ?? + this.subNavigationRailLoadingIndicatorBackgroundColor, + subNavigationRailDividerColor: + subNavigationRailDividerColor ?? this.subNavigationRailDividerColor, + chatSessionPaneDividerColor: + chatSessionPaneDividerColor ?? this.chatSessionPaneDividerColor, + chatSessionDetailsDrawerBackgroundColor: + chatSessionDetailsDrawerBackgroundColor ?? + this.chatSessionDetailsDrawerBackgroundColor, + chatSessionMessageTextStyle: + chatSessionMessageEditorTextStyle ?? chatSessionMessageTextStyle, + chatSessionMessageEmojiTextStyle: + chatSessionMessageEditorEmojiTextStyle ?? + chatSessionMessageEmojiTextStyle, + tileBackgroundColor: tileBackgroundColor ?? this.tileBackgroundColor, + tileBackgroundHighlightedColor: tileBackgroundHighlightedColor ?? + this.tileBackgroundHighlightedColor, + tileBackgroundHoveredColor: + tileBackgroundHoveredColor ?? this.tileBackgroundHoveredColor, + tileBackgroundFocusedColor: + tileBackgroundFocusedColor ?? this.tileBackgroundFocusedColor, + conversationTileMessageTextStyle: conversationTileMessageTextStyle ?? + this.conversationTileMessageTextStyle, + conversationTileHighlightedTextStyle: + conversationTileHighlightedTextStyle ?? + this.conversationTileHighlightedTextStyle, + conversationTileTimestampTextStyle: + conversationTileTimestampTextStyle ?? + this.conversationTileTimestampTextStyle, + messageAttachmentColor: + messageAttachmentColor ?? this.messageAttachmentColor, + messageAttachmentHoveredColor: + messageAttachmentHoveredColor ?? this.messageAttachmentHoveredColor, + messageBubbleErrorIconBackgroundColor: + messageBubbleErrorIconBackgroundColor ?? + this.messageBubbleErrorIconBackgroundColor, + messageBubbleErrorIconColor: + messageBubbleErrorIconColor ?? this.messageBubbleErrorIconColor, + fileTableTitleTextStyle: + fileTableTitleTextStyle ?? this.fileTableTitleTextStyle, + fileTableCellTextStyle: + fileTableCellTextStyle ?? this.fileTableCellTextStyle, + settingPageSubNavigationRailDividerColor: + settingPageSubNavigationRailDividerColor ?? + this.settingPageSubNavigationRailDividerColor, + dialogTitleTextStyleMedium: + dialogTitleTextStyleMedium ?? this.dialogTitleTextStyleMedium, + dialogTitleTextStyleLarge: + dialogTitleTextStyleLarge ?? this.dialogTitleTextStyleLarge, + descriptionTextStyle: descriptionTextStyle ?? this.descriptionTextStyle, + linkTextStyle: linkTextStyle ?? this.linkTextStyle, + linkHoveredTextStyle: linkHoveredTextStyle ?? this.linkHoveredTextStyle, + ); + + @override + AppThemeExtension lerp(covariant AppThemeExtension? other, double t) { + if (other is! AppThemeExtension) { + return this; + } + return AppThemeExtension( + themeMode: t < 0.5 ? themeMode : other.themeMode, + successColor: Color.lerp(successColor, other.successColor, t)!, + warningColor: Color.lerp(warningColor, other.warningColor, t)!, + errorColor: Color.lerp(errorColor, other.errorColor, t)!, + infoColor: Color.lerp(infoColor, other.infoColor, t)!, + dangerColor: Color.lerp(dangerColor, other.dangerColor, t)!, + dangerTextStyle: + TextStyle.lerp(dangerTextStyle, other.dangerTextStyle, t)!, + highlightTextStyle: + TextStyle.lerp(highlightTextStyle, other.highlightTextStyle, t)!, + maskColor: Color.lerp(maskColor, other.maskColor, t)!, + avatarIconColor: Color.lerp(avatarIconColor, other.avatarIconColor, t)!, + avatarBackgroundColor: + Color.lerp(avatarBackgroundColor, other.avatarBackgroundColor, t)!, + checkboxColor: Color.lerp(checkboxColor, other.checkboxColor, t)!, + checkboxTextStyle: + TextStyle.lerp(checkboxTextStyle, other.checkboxTextStyle, t)!, + iconButtonContainerHoveredColor: Color.lerp( + iconButtonContainerHoveredColor, + other.iconButtonContainerHoveredColor, + t)!, + iconButtonContainerPressedColor: Color.lerp( + iconButtonContainerPressedColor, + other.iconButtonContainerPressedColor, + t)!, + menuDecoration: + BoxDecoration.lerp(menuDecoration, other.menuDecoration, t)!, + menuItemColor: Color.lerp(menuItemColor, other.menuItemColor, t)!, + menuItemHoveredColor: + Color.lerp(menuItemHoveredColor, other.menuItemHoveredColor, t)!, + menuItemTextStyle: + TextStyle.lerp(menuItemTextStyle, other.menuItemTextStyle, t)!, + popupDecoration: + BoxDecoration.lerp(popupDecoration, other.popupDecoration, t)!, + tabTextStyle: TextStyle.lerp(tabTextStyle, other.tabTextStyle, t)!, + toastDecoration: + BoxDecoration.lerp(toastDecoration, other.toastDecoration, t)!, + homePageBackgroundColor: Color.lerp( + homePageBackgroundColor, other.homePageBackgroundColor, t)!, + mainNavigationRailBackgroundColor: Color.lerp( + mainNavigationRailBackgroundColor, + other.mainNavigationRailBackgroundColor, + t)!, + mainNavigationRailIconColor: Color.lerp( + mainNavigationRailIconColor, other.mainNavigationRailIconColor, t)!, + subNavigationRailSearchBarBackgroundColor: Color.lerp( + subNavigationRailSearchBarBackgroundColor, + other.subNavigationRailSearchBarBackgroundColor, + t)!, + subNavigationRailLoadingIndicatorBackgroundColor: Color.lerp( + subNavigationRailLoadingIndicatorBackgroundColor, + other.subNavigationRailLoadingIndicatorBackgroundColor, + t)!, + subNavigationRailDividerColor: Color.lerp(subNavigationRailDividerColor, + other.subNavigationRailDividerColor, t)!, + chatSessionPaneDividerColor: Color.lerp( + chatSessionPaneDividerColor, other.chatSessionPaneDividerColor, t)!, + chatSessionDetailsDrawerBackgroundColor: Color.lerp( + chatSessionDetailsDrawerBackgroundColor, + other.chatSessionDetailsDrawerBackgroundColor, + t)!, + chatSessionMessageTextStyle: TextStyle.lerp( + chatSessionMessageTextStyle, other.chatSessionMessageTextStyle, t)!, + chatSessionMessageEmojiTextStyle: TextStyle.lerp( + chatSessionMessageEmojiTextStyle, + other.chatSessionMessageEmojiTextStyle, + t)!, + tileBackgroundColor: + Color.lerp(tileBackgroundColor, other.tileBackgroundColor, t)!, + tileBackgroundHighlightedColor: Color.lerp(tileBackgroundHighlightedColor, + other.tileBackgroundHighlightedColor, t)!, + tileBackgroundHoveredColor: Color.lerp( + tileBackgroundHoveredColor, other.tileBackgroundHoveredColor, t)!, + tileBackgroundFocusedColor: Color.lerp( + tileBackgroundFocusedColor, other.tileBackgroundFocusedColor, t)!, + conversationTileMessageTextStyle: TextStyle.lerp( + conversationTileMessageTextStyle, + other.conversationTileMessageTextStyle, + t)!, + conversationTileHighlightedTextStyle: TextStyle.lerp( + conversationTileHighlightedTextStyle, + other.conversationTileHighlightedTextStyle, + t)!, + conversationTileTimestampTextStyle: TextStyle.lerp( + conversationTileTimestampTextStyle, + other.conversationTileTimestampTextStyle, + t)!, + messageAttachmentColor: + Color.lerp(messageAttachmentColor, other.messageAttachmentColor, t)!, + messageAttachmentHoveredColor: Color.lerp(messageAttachmentHoveredColor, + other.messageAttachmentHoveredColor, t)!, + messageBubbleErrorIconBackgroundColor: Color.lerp( + messageBubbleErrorIconBackgroundColor, + other.messageBubbleErrorIconBackgroundColor, + t)!, + messageBubbleErrorIconColor: Color.lerp( + messageBubbleErrorIconColor, other.messageBubbleErrorIconColor, t)!, + fileTableTitleTextStyle: TextStyle.lerp( + fileTableTitleTextStyle, other.fileTableTitleTextStyle, t)!, + fileTableCellTextStyle: TextStyle.lerp( + fileTableCellTextStyle, other.fileTableCellTextStyle, t)!, + settingPageSubNavigationRailDividerColor: Color.lerp( + settingPageSubNavigationRailDividerColor, + other.settingPageSubNavigationRailDividerColor, + t)!, + dialogTitleTextStyleMedium: TextStyle.lerp( + dialogTitleTextStyleMedium, other.dialogTitleTextStyleMedium, t)!, + dialogTitleTextStyleLarge: TextStyle.lerp( + dialogTitleTextStyleLarge, other.dialogTitleTextStyleLarge, t)!, + descriptionTextStyle: + TextStyle.lerp(descriptionTextStyle, other.descriptionTextStyle, t)!, + linkTextStyle: TextStyle.lerp(linkTextStyle, other.linkTextStyle, t)!, + linkHoveredTextStyle: + TextStyle.lerp(linkHoveredTextStyle, other.linkHoveredTextStyle, t)!, + ); + } +} + +extension BuildContextExtension on BuildContext { + ThemeData get theme => Theme.of(this); + + AppThemeExtension get appThemeExtension => + Theme.of(this).extension()!; +} + +extension ThemeDataExtension on ThemeData { + AppThemeExtension get appThemeExtension => extension()!; +} diff --git a/turms-chat-demo-flutter/lib/ui/themes/app_theme_view_model.dart b/turms-chat-demo-flutter/lib/ui/themes/app_theme_view_model.dart new file mode 100644 index 0000000000..ec0fe9e0ad --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/themes/app_theme_view_model.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../domain/user/view_models/user_settings_view_model.dart'; +import '../l10n/view_models/index.dart'; +import 'themes.dart'; + +final themeViewModel = StateProvider((ref) { + final observer = _PlatformBrightnessObserver(ref); + final binding = WidgetsBinding.instance..addObserver(observer); + ref.onDispose(() => binding.removeObserver(observer)); + + final userSettings = ref.watch(userSettingsViewModel); + final localeInfo = ref.watch(localeInfoViewModel); + final fontFamily = _getFontFamily(localeInfo.locale); + return switch (userSettings?.theme) { + ThemeMode.light => getLightTheme(fontFamily: fontFamily), + ThemeMode.dark => getDarkTheme(fontFamily: fontFamily), + _ => + _getThemeData(binding.platformDispatcher.platformBrightness, fontFamily), + }; +}); + +ThemeData _getThemeData(Brightness brightness, String? fontFamily) => + switch (brightness) { + Brightness.dark => getDarkTheme(fontFamily: fontFamily), + Brightness.light => getLightTheme(fontFamily: fontFamily), + }; + +String? _getFontFamily(Locale? locale) { + if (!Platform.isWindows) { + return null; + } + // FIXME: Used to fix the text rendering problem + // mentioned in https://github.com/flutter/flutter/issues/63043. + // Reference: https://learn.microsoft.com/en-us/windows/apps/design/globalizing/loc-international-fonts. + return switch (locale?.languageCode) { + 'ja' => 'Yu Gothic UI', + 'ko' => 'Malgun Gothic', + 'zh' || 'zh_CN' => 'Microsoft YaHei UI', + 'zh_HK' || 'zh_TW' => 'Microsoft JhengHei UI', + _ => null + }; +} + +class _PlatformBrightnessObserver extends WidgetsBindingObserver { + _PlatformBrightnessObserver(this.ref); + + final StateProviderRef ref; + + @override + void didChangePlatformBrightness() { + final brightness = + WidgetsBinding.instance.platformDispatcher.platformBrightness; + final userSettings = ref.read(userSettingsViewModel); + final localeInfo = ref.read(localeInfoViewModel); + + final themeMode = userSettings?.theme; + if (themeMode == null || themeMode == ThemeMode.system) { + ref.controller.state = + _getThemeData(brightness, _getFontFamily(localeInfo.locale)); + } + } +} diff --git a/turms-chat-demo-flutter/lib/ui/themes/colors.dart b/turms-chat-demo-flutter/lib/ui/themes/colors.dart new file mode 100644 index 0000000000..1231530dc3 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/themes/colors.dart @@ -0,0 +1,27 @@ +import 'dart:ui'; + +class AppColors { + AppColors._(); + + static const primary = Color(0xff1890ff); + + // base color palettes + + static const green6 = Color(0xFF52c41a); + static const red5 = Color(0xFFFF4D4F); + static const gold6 = Color(0xFFfaad14); + + static const blue5 = Color(0xFF4096FF); + static const blue6 = Color(0xFF1677FF); + + static const gray5 = Color(0xFFD9D9D9); + static const gray6 = Color(0xFFbfbfbf); + static const gray7 = Color(0xFF8c8c8c); + static const gray9 = Color(0xFF434343); + + // utils + + /// Colors.white.withValues(alpha: 0.0) + /// Reference: https://github.com/flutter/flutter/issues/14151#issuecomment-424104489 + static const transparentWhite = Color(0x00FFFFFF); +} diff --git a/turms-chat-demo-flutter/lib/ui/themes/fonts.dart b/turms-chat-demo-flutter/lib/ui/themes/fonts.dart new file mode 100644 index 0000000000..ee2929173d --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/themes/fonts.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; + +class Fonts { + Fonts._(); + + static final emojiFontFamily = switch (defaultTargetPlatform) { + TargetPlatform.iOS || TargetPlatform.macOS => 'Apple Color Emoji', + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.linux => + 'Noto Color Emoji', + TargetPlatform.windows => 'Segoe UI Emoji' + }; + static const emojiFontFamilyFallback = [ + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Segoe UI Symbol', + 'Noto Color Emoji', + 'Noto Color Emoji Compat', + 'Android Emoji', + 'EmojiSymbols' + ]; +} diff --git a/turms-chat-demo-flutter/lib/ui/themes/index.dart b/turms-chat-demo-flutter/lib/ui/themes/index.dart new file mode 100644 index 0000000000..ebb2f1c09f --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/themes/index.dart @@ -0,0 +1,7 @@ +export 'app_theme_extension.dart'; +export 'app_theme_view_model.dart'; +export 'colors.dart'; +export 'fonts.dart'; +export 'sizes.dart'; +export 'styles.dart'; +export 'themes.dart'; diff --git a/turms-chat-demo-flutter/lib/ui/themes/sizes.dart b/turms-chat-demo-flutter/lib/ui/themes/sizes.dart new file mode 100644 index 0000000000..122eaaad02 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/themes/sizes.dart @@ -0,0 +1,92 @@ +import 'package:flutter/widgets.dart'; + +import '../desktop/components/t_divider/t_vertical_divider.dart'; +import '../desktop/components/t_list_tile/t_list_tile.dart'; + +/// Principals: +/// The 2x Grid is the geometric foundation of all the visual elements +class Sizes { + const Sizes._(); + + // Space + static const paddingH1 = EdgeInsets.symmetric(horizontal: 1); + static const paddingV2 = EdgeInsets.symmetric(vertical: 2); + static const paddingH2 = EdgeInsets.symmetric(horizontal: 2); + static const paddingV2H4 = EdgeInsets.symmetric(vertical: 2, horizontal: 4); + static const paddingV4 = EdgeInsets.symmetric(vertical: 4); + static const paddingV4H8 = EdgeInsets.symmetric(vertical: 4, horizontal: 8); + static const paddingV4H4 = EdgeInsets.symmetric(vertical: 4, horizontal: 4); + static const paddingH4 = EdgeInsets.symmetric(horizontal: 4); + static const paddingV8 = EdgeInsets.symmetric(vertical: 8); + static const paddingV8H8 = EdgeInsets.symmetric(vertical: 8, horizontal: 8); + static const paddingV8H16 = EdgeInsets.symmetric(vertical: 8, horizontal: 16); + static const paddingH12 = EdgeInsets.symmetric(horizontal: 12); + static const paddingV16 = EdgeInsets.symmetric(vertical: 16); + static const paddingH16 = EdgeInsets.symmetric(horizontal: 16); + static const paddingV16H8 = EdgeInsets.symmetric(vertical: 16, horizontal: 8); + static const paddingV16H16 = + EdgeInsets.symmetric(vertical: 16, horizontal: 16); + + static const paddingH8 = EdgeInsets.symmetric(horizontal: 8); + + // Radius + static const borderRadius0 = BorderRadius.zero; + static const borderRadiusCircular2 = BorderRadius.all(Radius.circular(2)); + static const borderRadiusCircular4 = BorderRadius.all(Radius.circular(4)); + static const borderRadiusCircular8 = BorderRadius.all(Radius.circular(8)); + + // Sized boxes + static const sizedBox0 = SizedBox.shrink(); + static const sizedBoxInfinity = SizedBox.expand(); + static const sizedBoxW4 = SizedBox(width: 4); + static const sizedBoxH4 = SizedBox(height: 4); + static const sizedBoxW8 = SizedBox(width: 8); + static const sizedBoxH8 = SizedBox(height: 8); + static const sizedBoxH12 = SizedBox(height: 12); + static const sizedBoxW16 = SizedBox(width: 16); + static const sizedBoxH16 = SizedBox(height: 16); + static const sizedBoxH32 = SizedBox(height: 32); + + // Components + static const alertWidth = 320.0; + static const alertHeight = 140.0; + + static const dialogWidthMedium = 552.0; + static const dialogHeightMedium = 472.0; + + static const dateRangePickerWidth = 576.0; + static const dateRangePickerHeight = 312.0; + + // Application + static const mainNavigationRailWidth = 56.0; + static const subNavigationRailWidth = 248.0; + static const subNavigationRailMinWidth = 240.0; + static const subNavigationRailMaxWidth = 480.0; + static const subNavigationRailPadding = Sizes.paddingH12; + static const subNavigationRailDividerSize = + TMovableVerticalDividerSize.medium; + static const homePageHeaderHeight = defaultListTile; + static const conversationTileHeight = defaultListTile; + + static const mainNavigationRailElementPopupOffsetX = 4.0; + + static const userProfilePopupWidth = 280.0; + static const userProfilePopupHeight = 160.0; + + static const aboutPageWidth = 440.0; + static const aboutPageHeight = 300.0; + + static const stickerPickerWidth = 460.0; + static const stickerPickerHeight = 460.0; + + static const chatHistoryDialogWidth = 696.0; + static const chatHistoryDialogHeight = 640.0; + + static const userProfileImageDialogWidth = 520.0; + static const userProfileImageDialogHeight = 440.0; + + static const friendRequestDialogWidth = 400.0; + static const friendRequestDialogHeight = 300.0; + + static const titleBarSize = Size(36, 28); +} diff --git a/turms-chat-demo-flutter/lib/ui/themes/styles.dart b/turms-chat-demo-flutter/lib/ui/themes/styles.dart new file mode 100644 index 0000000000..733e56479e --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/themes/styles.dart @@ -0,0 +1,14 @@ +import 'package:flutter/widgets.dart'; + +class Styles { + Styles._(); + + // Shadows + static const boxShadow = [ + BoxShadow( + // Colors.black.withValues(alpha: 0.2), + color: Color(0x33000000), + blurRadius: 2, + blurStyle: BlurStyle.outer), + ]; +} diff --git a/turms-chat-demo-flutter/lib/ui/themes/themes.dart b/turms-chat-demo-flutter/lib/ui/themes/themes.dart new file mode 100644 index 0000000000..a6384abff6 --- /dev/null +++ b/turms-chat-demo-flutter/lib/ui/themes/themes.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import 'index.dart'; + +ThemeData getLightTheme({String? fontFamily}) => ThemeData( + useMaterial3: true, + extensions: [AppThemeExtension.light], + dividerColor: AppColors.gray5, + colorScheme: const ColorScheme.light( + primary: AppColors.primary, + ), + fontFamily: fontFamily, + textSelectionTheme: const TextSelectionThemeData(cursorColor: Colors.black), + inputDecorationTheme: const InputDecorationTheme( + contentPadding: Sizes.paddingV4H8, border: InputBorder.none)); + +ThemeData getDarkTheme({String? fontFamily}) => ThemeData( + useMaterial3: true, + extensions: [AppThemeExtension.dark], + dividerColor: AppColors.gray5, + colorScheme: const ColorScheme.dark( + primary: AppColors.primary, + ), + fontFamily: fontFamily, + textSelectionTheme: const TextSelectionThemeData(cursorColor: Colors.black), + inputDecorationTheme: const InputDecorationTheme( + contentPadding: Sizes.paddingV4H8, border: InputBorder.none)); diff --git a/turms-chat-demo-flutter/linux/.gitignore b/turms-chat-demo-flutter/linux/.gitignore new file mode 100644 index 0000000000..d3896c9844 --- /dev/null +++ b/turms-chat-demo-flutter/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/turms-chat-demo-flutter/linux/CMakeLists.txt b/turms-chat-demo-flutter/linux/CMakeLists.txt new file mode 100644 index 0000000000..a4191086b3 --- /dev/null +++ b/turms-chat-demo-flutter/linux/CMakeLists.txt @@ -0,0 +1,145 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "turms_chat_demo") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "im.turms.turms_chat_demo") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/turms-chat-demo-flutter/linux/flutter/CMakeLists.txt b/turms-chat-demo-flutter/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000000..d5bd01648a --- /dev/null +++ b/turms-chat-demo-flutter/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/turms-chat-demo-flutter/linux/flutter/generated_plugin_registrant.cc b/turms-chat-demo-flutter/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..fd70d39813 --- /dev/null +++ b/turms-chat-demo-flutter/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,63 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_platform_alert_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterPlatformAlertPlugin"); + flutter_platform_alert_plugin_register_with_registrar(flutter_platform_alert_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin"); + irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar); + g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_video_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); + media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); + g_autoptr(FlPluginRegistrar) open_file_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); + open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); + sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); + g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); + super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); + g_autoptr(FlPluginRegistrar) tray_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); + tray_manager_plugin_register_with_registrar(tray_manager_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); +} diff --git a/turms-chat-demo-flutter/linux/flutter/generated_plugin_registrant.h b/turms-chat-demo-flutter/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..e0f0a47bc0 --- /dev/null +++ b/turms-chat-demo-flutter/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/turms-chat-demo-flutter/linux/flutter/generated_plugins.cmake b/turms-chat-demo-flutter/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..41c522abec --- /dev/null +++ b/turms-chat-demo-flutter/linux/flutter/generated_plugins.cmake @@ -0,0 +1,38 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + flutter_platform_alert + flutter_secure_storage_linux + irondash_engine_context + media_kit_libs_linux + media_kit_video + open_file_linux + screen_retriever_linux + sqlite3_flutter_libs + super_native_extensions + tray_manager + url_launcher_linux + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + media_kit_native_event_loop + rust_lib_turms_chat_demo +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/turms-chat-demo-flutter/linux/main.cc b/turms-chat-demo-flutter/linux/main.cc new file mode 100644 index 0000000000..e7c5c54370 --- /dev/null +++ b/turms-chat-demo-flutter/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/turms-chat-demo-flutter/linux/my_application.cc b/turms-chat-demo-flutter/linux/my_application.cc new file mode 100644 index 0000000000..0c536dcbc0 --- /dev/null +++ b/turms-chat-demo-flutter/linux/my_application.cc @@ -0,0 +1,131 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + GList *list = gtk_application_get_windows(GTK_APPLICATION(application)); + GtkWindow* existing_window = list ? GTK_WINDOW(list->data) : NULL; + if (existing_window) { + gtk_window_present(existing_window); + return; + } + + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_realize(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "turms_chat_demo"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "turms_chat_demo"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_realize(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_realize(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/turms-chat-demo-flutter/linux/my_application.h b/turms-chat-demo-flutter/linux/my_application.h new file mode 100644 index 0000000000..72271d5e41 --- /dev/null +++ b/turms-chat-demo-flutter/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/turms-chat-demo-flutter/macos/.gitignore b/turms-chat-demo-flutter/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/turms-chat-demo-flutter/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/turms-chat-demo-flutter/macos/Flutter/Flutter-Debug.xcconfig b/turms-chat-demo-flutter/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..c2efd0b608 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/turms-chat-demo-flutter/macos/Flutter/Flutter-Release.xcconfig b/turms-chat-demo-flutter/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..c2efd0b608 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/turms-chat-demo-flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/turms-chat-demo-flutter/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000000..8f9f265d8d --- /dev/null +++ b/turms-chat-demo-flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,58 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import device_info_plus +import file_selector_macos +import flutter_image_compress_macos +import flutter_inappwebview_macos +import flutter_local_notifications +import flutter_platform_alert +import flutter_secure_storage_macos +import gal +import irondash_engine_context +import media_kit_libs_macos_audio +import media_kit_libs_macos_video +import media_kit_video +import open_file_mac +import package_info_plus +import path_provider_foundation +import screen_brightness_macos +import screen_retriever_macos +import sqlite3_flutter_libs +import super_native_extensions +import tray_manager +import url_launcher_macos +import video_player_avfoundation +import wakelock_plus +import window_manager + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterPlatformAlertPlugin.register(with: registry.registrar(forPlugin: "FlutterPlatformAlertPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) + IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) + MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) + MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) + MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) + OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) + SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) + TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) +} diff --git a/turms-chat-demo-flutter/macos/Runner.xcodeproj/project.pbxproj b/turms-chat-demo-flutter/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..0ed199d1be --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,695 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* turms_chat_demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "turms_chat_demo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* turms_chat_demo.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* turms_chat_demo.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = im.turms.turmsChatDemo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/turms_chat_demo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/turms_chat_demo"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = im.turms.turmsChatDemo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/turms_chat_demo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/turms_chat_demo"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = im.turms.turmsChatDemo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/turms_chat_demo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/turms_chat_demo"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/turms-chat-demo-flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/turms-chat-demo-flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/turms-chat-demo-flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/turms-chat-demo-flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..43bb65b982 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/turms-chat-demo-flutter/macos/Runner.xcworkspace/contents.xcworkspacedata b/turms-chat-demo-flutter/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..1d526a16ed --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/turms-chat-demo-flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/turms-chat-demo-flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/turms-chat-demo-flutter/macos/Runner/AppDelegate.swift b/turms-chat-demo-flutter/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..d53ef64377 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/turms-chat-demo-flutter/macos/Runner/AppHostApi.g.swift b/turms-chat-demo-flutter/macos/Runner/AppHostApi.g.swift new file mode 100644 index 0000000000..0d35165698 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner/AppHostApi.g.swift @@ -0,0 +1,130 @@ +// Autogenerated from Pigeon (v17.1.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +/// Generated class from Pigeon that represents data sent in messages. +struct DiskSpaceInfo { + var total: Int64 + var free: Int64 + var usable: Int64 + + static func fromList(_ list: [Any?]) -> DiskSpaceInfo? { + let total = list[0] is Int64 ? list[0] as! Int64 : Int64(list[0] as! Int32) + let free = list[1] is Int64 ? list[1] as! Int64 : Int64(list[1] as! Int32) + let usable = list[2] is Int64 ? list[2] as! Int64 : Int64(list[2] as! Int32) + + return DiskSpaceInfo( + total: total, + free: free, + usable: usable + ) + } + func toList() -> [Any?] { + return [ + total, + free, + usable, + ] + } +} +private class AppHostApiCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 128: + return DiskSpaceInfo.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class AppHostApiCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? DiskSpaceInfo { + super.writeByte(128) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class AppHostApiCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return AppHostApiCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return AppHostApiCodecWriter(data: data) + } +} + +class AppHostApiCodec: FlutterStandardMessageCodec { + static let shared = AppHostApiCodec(readerWriter: AppHostApiCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol AppHostApi { + func getDiskSpace(path: String) throws -> DiskSpaceInfo +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class AppHostApiSetup { + /// The codec used by AppHostApi. + static var codec: FlutterStandardMessageCodec { AppHostApiCodec.shared } + /// Sets up an instance of `AppHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: AppHostApi?) { + let getDiskSpaceChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.turms_chat_demo.AppHostApi.getDiskSpace", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getDiskSpaceChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let pathArg = args[0] as! String + do { + let result = try api.getDiskSpace(path: pathArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getDiskSpaceChannel.setMessageHandler(nil) + } + } +} diff --git a/turms-chat-demo-flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/turms-chat-demo-flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/turms-chat-demo-flutter/macos/Runner/Base.lproj/MainMenu.xib b/turms-chat-demo-flutter/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000000..80e867a4e0 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/turms-chat-demo-flutter/macos/Runner/Configs/AppInfo.xcconfig b/turms-chat-demo-flutter/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..19255c2f03 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = turms_chat_demo + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = im.turms.turmsChatDemo + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2024 im.turms. All rights reserved. diff --git a/turms-chat-demo-flutter/macos/Runner/Configs/Debug.xcconfig b/turms-chat-demo-flutter/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/turms-chat-demo-flutter/macos/Runner/Configs/Release.xcconfig b/turms-chat-demo-flutter/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/turms-chat-demo-flutter/macos/Runner/Configs/Warnings.xcconfig b/turms-chat-demo-flutter/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/turms-chat-demo-flutter/macos/Runner/DebugProfile.entitlements b/turms-chat-demo-flutter/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..dddb8a30c8 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/turms-chat-demo-flutter/macos/Runner/Info.plist b/turms-chat-demo-flutter/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/turms-chat-demo-flutter/macos/Runner/MainFlutterWindow.swift b/turms-chat-demo-flutter/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..2ef5403f05 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,33 @@ +import Cocoa +import FlutterMacOS + +private class AppHostApiImpl: AppHostApi { + func getDiskSpace(path: String) throws -> DiskSpaceInfo { + let fileURL = URL(fileURLWithPath: path) + do { + let values = try fileURL.resourceValues(forKeys: [.volumeTotalCapacityKey]) + let total = values.volumeTotalCapacity + let free = values.volumeAvailableCapacityForImportantUsage + return DiskSpaceInfo(total, free, free) + } catch { + return DiskSpaceInfo(-1, -1, -1) + } + } +} + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + let hostApi = AppHostApiImpl() + AppHostApiSetup.setUp( + binaryMessenger: flutterViewController.engine.binaryMessenger, api: hostApi) + + super.awakeFromNib() + } +} \ No newline at end of file diff --git a/turms-chat-demo-flutter/macos/Runner/Release.entitlements b/turms-chat-demo-flutter/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..852fa1a472 --- /dev/null +++ b/turms-chat-demo-flutter/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/turms-chat-demo-flutter/macos/RunnerTests/RunnerTests.swift b/turms-chat-demo-flutter/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..5418c9f539 --- /dev/null +++ b/turms-chat-demo-flutter/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import FlutterMacOS +import Cocoa +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/turms-chat-demo-flutter/pubspec.lock b/turms-chat-demo-flutter/pubspec.lock new file mode 100644 index 0000000000..08e7399a56 --- /dev/null +++ b/turms-chat-demo-flutter/pubspec.lock @@ -0,0 +1,2169 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + url: "https://pub.dev" + source: hosted + version: "76.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.3" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + url: "https://pub.dev" + source: hosted + version: "6.11.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" + url: "https://pub.dev" + source: hosted + version: "0.11.3" + animations: + dependency: "direct main" + description: + name: animations + sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb + url: "https://pub.dev" + source: hosted + version: "2.0.11" + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + async: + dependency: "direct main" + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + background_downloader: + dependency: "direct main" + description: + name: background_downloader + sha256: "7c65a999544b40668000aac6379854b890f0ef367b9fbd29d959fd1e242dda14" + url: "https://pub.dev" + source: hosted + version: "8.8.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: b59d2769769efd6c9ff6d4c4cede0be115a566afc591705c2040b707534b1172 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + build_config: + dependency: transitive + description: + name: build_config + sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + url: "https://pub.dev" + source: hosted + version: "1.1.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + url: "https://pub.dev" + source: hosted + version: "2.4.13" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + url: "https://pub.dev" + source: hosted + version: "7.3.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + url: "https://pub.dev" + source: hosted + version: "8.9.2" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + collection: + dependency: transitive + description: + name: collection + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.dev" + source: hosted + version: "1.19.0" + color: + dependency: transitive + description: + name: color + sha256: ddcdf1b3badd7008233f5acffaf20ca9f5dc2cd0172b75f68f24526a5f5725cb + url: "https://pub.dev" + source: hosted + version: "3.0.0" + convert: + dependency: "direct main" + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: "direct main" + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "3486c470bb93313a9417f926c7dd694a2e349220992d7b9d14534dc49c15bba9" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + custom_lint_builder: + dependency: transitive + description: + name: custom_lint_builder + sha256: "42cdc41994eeeddab0d7a722c7093ec52bd0761921eeb2cbdbf33d192a234759" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "02450c3e45e2a6e8b26c4d16687596ab3c4644dd5792e3313aa9ceba5a49b7f5" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: bfe9b7a09c4775a587b58d10ebb871d4fe618237639b1e84d5ec62d7dfef25f9 + url: "https://pub.dev" + source: hosted + version: "1.0.0+6.11.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" + url: "https://pub.dev" + source: hosted + version: "2.3.7" + dartx: + dependency: transitive + description: + name: dartx + sha256: "8b25435617027257d43e6508b5fe061012880ddfdaa75a71d607c3de2a13d244" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: f545ffbadee826f26f2e1a0f0cbd667ae9a6011cc0f77c0f8f00a969655e6e95 + url: "https://pub.dev" + source: hosted + version: "11.1.1" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + dotted_border: + dependency: "direct main" + description: + name: dotted_border + sha256: "108837e11848ca776c53b30bc870086f84b62ed6e01c503ed976e8f8c7df9c04" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + drift: + dependency: "direct main" + description: + name: drift + sha256: c2d073d35ad441730812f4ea05b5dd031fb81c5f9786a4f5fb77ecd6307b6f74 + url: "https://pub.dev" + source: hosted + version: "2.22.1" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: f4ab5d6976b1e31551ceb82ff597a505bda7818ff4f7be08a1da9d55eb6e730c + url: "https://pub.dev" + source: hosted + version: "2.22.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "16dc141db5a2ccc6520ebb6a2eb5945b1b09e95085c021d9f914f8ded7f1465c" + url: "https://pub.dev" + source: hosted + version: "8.1.4" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "8f5d2f6590d51ecd9179ba39c64f722edc15226cc93dcc8698466ad36a4a85a4" + url: "https://pub.dev" + source: hosted + version: "0.9.3+3" + fixnum: + dependency: "direct main" + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_gen_core: + dependency: transitive + description: + name: flutter_gen_core + sha256: "46ecf0e317413dd065547887c43f93f55e9653e83eb98dc13dd07d40dd225325" + url: "https://pub.dev" + source: hosted + version: "5.8.0" + flutter_gen_runner: + dependency: "direct dev" + description: + name: flutter_gen_runner + sha256: "77f0a02fc30d9fcf2549fe874eb3fde091435724904bcbb1af60aa40cbfab1f4" + url: "https://pub.dev" + source: hosted + version: "5.8.0" + flutter_image_compress: + dependency: "direct main" + description: + name: flutter_image_compress + sha256: "45a3071868092a61b11044c70422b04d39d4d9f2ef536f3c5b11fb65a1e7dd90" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_image_compress_common: + dependency: transitive + description: + name: flutter_image_compress_common + sha256: "7f79bc6c8a363063620b4e372fa86bc691e1cb28e58048cd38e030692fbd99ee" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + flutter_image_compress_macos: + dependency: transitive + description: + name: flutter_image_compress_macos + sha256: "26df6385512e92b3789dc76b613b54b55c457a7f1532e59078b04bf189782d47" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + flutter_image_compress_ohos: + dependency: transitive + description: + name: flutter_image_compress_ohos + sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51 + url: "https://pub.dev" + source: hosted + version: "0.0.3" + flutter_image_compress_platform_interface: + dependency: transitive + description: + name: flutter_image_compress_platform_interface + sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + flutter_image_compress_web: + dependency: transitive + description: + name: flutter_image_compress_web + sha256: f02fe352b17f82b72f481de45add240db062a2585850bea1667e82cc4cd6c311 + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 + url: "https://pub.dev" + source: hosted + version: "1.3.0+1" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + flutter_keyboard_visibility: + dependency: "direct main" + description: + name: flutter_keyboard_visibility + sha256: "98664be7be0e3ffca00de50f7f6a287ab62c763fc8c762e0a21584584a3ff4f8" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_keyboard_visibility_linux: + dependency: transitive + description: + name: flutter_keyboard_visibility_linux + sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_macos: + dependency: transitive + description: + name: flutter_keyboard_visibility_macos + sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_platform_interface: + dependency: transitive + description: + name: flutter_keyboard_visibility_platform_interface + sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_web: + dependency: transitive + description: + name: flutter_keyboard_visibility_web + sha256: d3771a2e752880c79203f8d80658401d0c998e4183edca05a149f5098ce6e3d1 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_windows: + dependency: transitive + description: + name: flutter_keyboard_visibility_windows + sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "31cd0885738e87c72d6f055564d37fabcdacee743b396b78c7636c169cac64f5" + url: "https://pub.dev" + source: hosted + version: "0.14.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_platform_alert: + dependency: "direct dev" + description: + name: flutter_platform_alert + sha256: "29a27c81660468bfb7746bc78205f79e07f18ca785e0aeaf70eac28d7a011edb" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398" + url: "https://pub.dev" + source: hosted + version: "2.0.23" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_rust_bridge: + dependency: "direct main" + description: + name: flutter_rust_bridge + sha256: fb9d3c9395eae3c71d4fe3ec343b9f30636c9988150c8bb33b60047549b34e3d + url: "https://pub.dev" + source: hosted + version: "2.6.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0" + url: "https://pub.dev" + source: hosted + version: "9.2.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "54900a1a1243f3c4a5506d853a2b5c2dbc38d5f27e52a52618a8054401431123" + url: "https://pub.dev" + source: hosted + version: "2.0.16" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + gal: + dependency: "direct main" + description: + name: gal + sha256: "54c9b72528efce7c66234f3b6dd01cb0304fd8af8196de15571d7bdddb940977" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hashcodes: + dependency: transitive + description: + name: hashcodes + sha256: "80f9410a5b3c8e110c4b7604546034749259f5d6dcca63e0d3c17c9258f1a651" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e + url: "https://pub.dev" + source: hosted + version: "4.2.0" + html: + dependency: transitive + description: + name: html + sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" + url: "https://pub.dev" + source: hosted + version: "0.15.5" + http: + dependency: "direct main" + description: + name: http + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + image: + dependency: "direct main" + description: + name: image + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + url: "https://pub.dev" + source: hosted + version: "4.3.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: fa8141602fde3f7e2f81dbf043613eb44dfa325fa0bcf93c0f142c9f7a2c193e + url: "https://pub.dev" + source: hosted + version: "0.8.12+18" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "4f0568120c6fcc0aaa04511cb9f9f4d29fc3d0139884b1d06be88dcec7641d6b" + url: "https://pub.dev" + source: hosted + version: "0.8.12+1" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_size_getter: + dependency: transitive + description: + name: image_size_getter + sha256: "0511799498340b70993d2dfb34b55a2247b5b801d75a6cdd4543acfcafdb12b0" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + irondash_engine_context: + dependency: transitive + description: + name: irondash_engine_context + sha256: cd7b769db11a2b5243b037c8a9b1ecaef02e1ae27a2d909ffa78c1dad747bb10 + url: "https://pub.dev" + source: hosted + version: "0.5.4" + irondash_message_channel: + dependency: transitive + description: + name: irondash_message_channel + sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060 + url: "https://pub.dev" + source: hosted + version: "0.7.0" + isolate_manager: + dependency: "direct main" + description: + name: isolate_manager + sha256: acbfd1bc06fbc6ef23ef8b0c1f5279321cee71bd2f6c97e3be6260df6cfa2186 + url: "https://pub.dev" + source: hosted + version: "5.7.0+1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_rpc_2: + dependency: "direct main" + description: + name: json_rpc_2 + sha256: "246b321532f0e8e2ba474b4d757eaa558ae4fdd0688fdbc1e1ca9705f9b8ca0e" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + url: "https://pub.dev" + source: hosted + version: "10.0.7" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + url: "https://pub.dev" + source: hosted + version: "3.0.8" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "4a16b3f03741e1252fda5de3ce712666d010ba2122f8e912c94f9f7b90e1a4c3" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + macros: + dependency: transitive + description: + name: macros + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" + url: "https://pub.dev" + source: hosted + version: "0.1.3-main.0" + markdown: + dependency: "direct main" + description: + name: markdown + sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + url: "https://pub.dev" + source: hosted + version: "7.2.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + material_symbols_icons: + dependency: "direct main" + description: + name: material_symbols_icons + sha256: "64404f47f8e0a9d20478468e5decef867a688660bad7173adcd20418d7f892c9" + url: "https://pub.dev" + source: hosted + version: "4.2801.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: "1f1deee148533d75129a6f38251ff8388e33ee05fc2d20a6a80e57d6051b7b62" + url: "https://pub.dev" + source: hosted + version: "1.1.11" + media_kit_libs_android_audio: + dependency: transitive + description: + name: media_kit_libs_android_audio + sha256: "3d2df5c09d3f3ff7c55b53bf955e46712f76483e77562a5a017439a3ea85ce88" + url: "https://pub.dev" + source: hosted + version: "1.3.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "9dd8012572e4aff47516e55f2597998f0a378e3d588d0fad0ca1f11a53ae090c" + url: "https://pub.dev" + source: hosted + version: "1.3.6" + media_kit_libs_audio: + dependency: "direct main" + description: + name: media_kit_libs_audio + sha256: be40e17c4cb7bd4e14114dce24a36e645f2ac5989dda543deaba2e7873901ba0 + url: "https://pub.dev" + source: hosted + version: "1.0.5" + media_kit_libs_ios_audio: + dependency: transitive + description: + name: media_kit_libs_ios_audio + sha256: "78ccf04e27d6b4ba00a355578ccb39b772f00d48269a6ac3db076edf2d51934f" + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: e186891c31daa6bedab4d74dcdb4e8adfccc7d786bfed6ad81fe24a3b3010310 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + media_kit_libs_macos_audio: + dependency: transitive + description: + name: media_kit_libs_macos_audio + sha256: "3be21844df98f286de32808592835073cdef2c1a10078bac135da790badca950" + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "20bb4aefa8fece282b59580e1cd8528117297083a6640c98c2e98cfc96b93288" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + media_kit_libs_windows_audio: + dependency: transitive + description: + name: media_kit_libs_windows_audio + sha256: c2fd558cc87b9d89a801141fcdffe02e338a3b21a41a18fbd63d5b221a1b8e53 + url: "https://pub.dev" + source: hosted + version: "1.0.9" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: "32654572167825c42c55466f5d08eee23ea11061c84aa91b09d0e0f69bdd0887" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + media_kit_native_event_loop: + dependency: transitive + description: + name: media_kit_native_event_loop + sha256: "7d82e3b3e9ded5c35c3146c5ba1da3118d1dd8ac3435bac7f29f458181471b40" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: "2cc3b966679963ba25a4ce5b771e532a521ebde7c6aa20e9802bec95d9916c8f" + url: "https://pub.dev" + source: hosted + version: "1.2.5" + menu_base: + dependency: transitive + description: + name: menu_base + sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + open_file: + dependency: "direct main" + description: + name: open_file + sha256: d17e2bddf5b278cb2ae18393d0496aa4f162142ba97d1a9e0c30d476adf99c0e + url: "https://pub.dev" + source: hosted + version: "3.5.10" + open_file_android: + dependency: transitive + description: + name: open_file_android + sha256: "58141fcaece2f453a9684509a7275f231ac0e3d6ceb9a5e6de310a7dff9084aa" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + open_file_ios: + dependency: transitive + description: + name: open_file_ios + sha256: "02996f01e5f6863832068e97f8f3a5ef9b613516db6897f373b43b79849e4d07" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + open_file_linux: + dependency: transitive + description: + name: open_file_linux + sha256: d189f799eecbb139c97f8bc7d303f9e720954fa4e0fa1b0b7294767e5f2d7550 + url: "https://pub.dev" + source: hosted + version: "0.0.5" + open_file_mac: + dependency: transitive + description: + name: open_file_mac + sha256: "1440b1e37ceb0642208cfeb2c659c6cda27b25187a90635c9d1acb7d0584d324" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + open_file_platform_interface: + dependency: transitive + description: + name: open_file_platform_interface + sha256: "101b424ca359632699a7e1213e83d025722ab668b9fd1412338221bf9b0e5757" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + open_file_web: + dependency: transitive + description: + name: open_file_web + sha256: e3dbc9584856283dcb30aef5720558b90f88036360bd078e494ab80a80130c4f + url: "https://pub.dev" + source: hosted + version: "0.0.4" + open_file_windows: + dependency: transitive + description: + name: open_file_windows + sha256: d26c31ddf935a94a1a3aa43a23f4fff8a5ff4eea395fe7a8cb819cf55431c875 + url: "https://pub.dev" + source: hosted + version: "0.0.3" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce + url: "https://pub.dev" + source: hosted + version: "8.1.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + path: + dependency: "direct main" + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_drawing: + dependency: transitive + description: + name: path_drawing + sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977 + url: "https://pub.dev" + source: hosted + version: "1.0.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "8c4967f8b7cb46dc914e178daa29813d83ae502e0529d7b0478330616a691ef7" + url: "https://pub.dev" + source: hosted + version: "2.2.14" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + pixel_snap: + dependency: transitive + description: + name: pixel_snap + sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0" + url: "https://pub.dev" + source: hosted + version: "0.1.5" + platform: + dependency: transitive + description: + name: platform + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + process: + dependency: transitive + description: + name: process + sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + protobuf: + dependency: "direct main" + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + pub_semver: + dependency: "direct main" + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + responsive_framework: + dependency: "direct main" + description: + name: responsive_framework + sha256: a8e1c13d4ba980c60cbf6fa1e9907cd60662bf2585184d7c96ca46c43de91552 + url: "https://pub.dev" + source: hosted + version: "1.5.1" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: c6b8222b2b483cb87ae77ad147d6408f400c64f060df7a225b127f4afef4f8c8 + url: "https://pub.dev" + source: hosted + version: "0.5.8" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "83e4caa337a9840469b7b9bd8c2351ce85abad80f570d84146911b32086fbd99" + url: "https://pub.dev" + source: hosted + version: "2.6.3" + rust_lib_turms_chat_demo: + dependency: "direct main" + description: + path: rust_builder + relative: true + source: path + version: "0.0.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: ede4eb6cb7d88a116b3d3bf1df70790b9e2038bc37cb19112e381217c74d9440 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + screen_brightness: + dependency: transitive + description: + name: screen_brightness + sha256: ed8da4a4511e79422fc1aa88138e920e4008cd312b72cdaa15ccb426c0faaedd + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + screen_brightness_android: + dependency: transitive + description: + name: screen_brightness_android + sha256: "3df10961e3a9e968a5e076fe27e7f4741fa8a1d3950bdeb48cf121ed529d0caf" + url: "https://pub.dev" + source: hosted + version: "0.1.0+2" + screen_brightness_ios: + dependency: transitive + description: + name: screen_brightness_ios + sha256: "99adc3ca5490b8294284aad5fcc87f061ad685050e03cf45d3d018fe398fd9a2" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + screen_brightness_macos: + dependency: transitive + description: + name: screen_brightness_macos + sha256: "64b34e7e3f4900d7687c8e8fb514246845a73ecec05ab53483ed025bd4a899fd" + url: "https://pub.dev" + source: hosted + version: "0.1.0+1" + screen_brightness_platform_interface: + dependency: transitive + description: + name: screen_brightness_platform_interface + sha256: b211d07f0c96637a15fb06f6168617e18030d5d74ad03795dd8547a52717c171 + url: "https://pub.dev" + source: hosted + version: "0.1.0" + screen_brightness_windows: + dependency: transitive + description: + name: screen_brightness_windows + sha256: "9261bf33d0fc2707d8cf16339ce25768100a65e70af0fcabaf032fc12408ba86" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: "direct main" + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + shortid: + dependency: transitive + description: + name: shortid + sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb + url: "https://pub.dev" + source: hosted + version: "0.1.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: cb7f4e9dc1b52b1fa350f7b3d41c662e75fc3d399555fa4e5efcf267e9a4fbb5 + url: "https://pub.dev" + source: hosted + version: "2.5.0" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + sha256: "636b0fe8a2de894e5455572f6cbbc458f4ffecfe9f860b79439e27041ea4f0b9" + url: "https://pub.dev" + source: hosted + version: "0.5.27" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "4cad4b2c5f63dc9ea1a8dcffb58cf762322bea5dd8836870164a65e913bdae41" + url: "https://pub.dev" + source: hosted + version: "0.40.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + super_clipboard: + dependency: "direct main" + description: + name: super_clipboard + sha256: "687ef5d4ceb2cb1e0e36a4af37683936609f424f0767b46fee5fc312b0aeb595" + url: "https://pub.dev" + source: hosted + version: "0.9.0-dev.5" + super_drag_and_drop: + dependency: "direct main" + description: + name: super_drag_and_drop + sha256: "2277a6d80c2db39cb95e13943f5d56192d5786aa32ad92eb41786c64bc8b4395" + url: "https://pub.dev" + source: hosted + version: "0.9.0-dev.5" + super_hot_key: + dependency: "direct main" + description: + name: super_hot_key + sha256: "7b9725ed150fbe35cf001e498a313f3539082bd9ddc3377ba7b808a7a9a4df6e" + url: "https://pub.dev" + source: hosted + version: "0.9.0-dev.5" + super_keyboard_layout: + dependency: transitive + description: + name: super_keyboard_layout + sha256: c7417e7d6b8ea2f8ad00f509ade23db4e90693a16b998d365b54ebbae771bb60 + url: "https://pub.dev" + source: hosted + version: "0.9.0-dev.5" + super_native_extensions: + dependency: transitive + description: + name: super_native_extensions + sha256: "1cb6baecf529300ae7f59974bdc33a53b947ecc4ce374c00126df064c10e4e51" + url: "https://pub.dev" + source: hosted + version: "0.9.0-dev.5" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.dev" + source: hosted + version: "0.7.3" + time: + dependency: transitive + description: + name: time + sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + timezone: + dependency: transitive + description: + name: timezone + sha256: ffc9d5f4d1193534ef051f9254063fa53d588609418c84299956c3db9383587d + url: "https://pub.dev" + source: hosted + version: "0.10.0" + timing: + dependency: transitive + description: + name: timing + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + tray_manager: + dependency: "direct main" + description: + name: tray_manager + sha256: "3c03c70a9b14e89b17c15275c05f67fdd30950f3073ae523755ad9beb2ac7e35" + url: "https://pub.dev" + source: hosted + version: "0.3.0" + turms_client_dart: + dependency: "direct main" + description: + path: "../turms-client-dart" + relative: true + source: path + version: "1.0.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_html: + dependency: "direct main" + description: + name: universal_html + sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" + url: "https://pub.dev" + source: hosted + version: "2.2.4" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "6543c9fd86d2862fac55d800a43e67c0dcd1a41677cb69c2f8edfe73bbcf1835" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + url: "https://pub.dev" + source: hosted + version: "6.3.14" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "27d5fefe86fb9aace4a9f8375b56b3c292b64d8c04510df230f849850d912cb7" + url: "https://pub.dev" + source: hosted + version: "1.1.15" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "2430b973a4ca3c4dbc9999b62b8c719a160100dcbae5c819bae0cacce32c9cdb" + url: "https://pub.dev" + source: hosted + version: "1.1.12" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" + url: "https://pub.dev" + source: hosted + version: "1.1.16" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "4a8c3492d734f7c39c2588a3206707a05ee80cef52e8c7f3b2078d430c84bc17" + url: "https://pub.dev" + source: hosted + version: "2.9.2" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "391e092ba4abe2f93b3e625bd6b6a6ec7d7414279462c1c0ee42b5ab8d0a0898" + url: "https://pub.dev" + source: hosted + version: "2.7.16" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: f498e44a547a3572a928fa30ac8760e127d5e5fc86b81b10b0d56300866322f3 + url: "https://pub.dev" + source: hosted + version: "2.6.4" + video_player_media_kit: + dependency: "direct main" + description: + name: video_player_media_kit + sha256: eadf78b85d0ecc6f65bb5ca84c5ad9546a8609c6c0ee207e81673f7969461f3b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "229d7642ccd9f3dc4aba169609dd6b5f3f443bb4cc15b82f7785fcada5af9bbb" + url: "https://pub.dev" + source: hosted + version: "6.2.3" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "881b375a934d8ebf868c7fb1423b2bfaa393a0a265fa3f733079a86536064a10" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + visibility_detector: + dependency: "direct main" + description: + name: visibility_detector + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 + url: "https://pub.dev" + source: hosted + version: "0.4.0+2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" + source: hosted + version: "14.3.0" + volume_controller: + dependency: transitive + description: + name: volume_controller + sha256: c71d4c62631305df63b72da79089e078af2659649301807fa746088f365cb48e + url: "https://pub.dev" + source: hosted + version: "2.0.8" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: bf4ee6f17a2fa373ed3753ad0e602b7603f8c75af006d5b9bdade263928c0484 + url: "https://pub.dev" + source: hosted + version: "1.2.8" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "422d1cdbb448079a8a62a5a770b69baa489f8f7ca21aef47800c726d404f9d16" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" + url: "https://pub.dev" + source: hosted + version: "3.0.4" + win32: + dependency: transitive + description: + name: win32 + sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69" + url: "https://pub.dev" + source: hosted + version: "5.9.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.dev" + source: hosted + version: "1.1.5" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "732896e1416297c63c9e3fb95aea72d0355f61390263982a47fd519169dc5059" + url: "https://pub.dev" + source: hosted + version: "0.4.3" + windows_notification: + dependency: "direct main" + description: + name: windows_notification + sha256: be3e650874615f315402c9b9f3656e29af156709c4b5cc272cb4ca0ab7ba94a8 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.6.0-0 <4.0.0" + flutter: ">=3.24.0" diff --git a/turms-chat-demo-flutter/pubspec.yaml b/turms-chat-demo-flutter/pubspec.yaml new file mode 100644 index 0000000000..5119056079 --- /dev/null +++ b/turms-chat-demo-flutter/pubspec.yaml @@ -0,0 +1,167 @@ +name: turms_chat_demo +publish_to: 'none' +version: 0.0.1 + +platforms: + linux: + macos: + windows: +# TODO: Add support for the following platforms: +# web: +# android: +# ios: + +environment: + sdk: '>=3.4.0 <4.0.0' + flutter: ">=3.24.0 <4.0.0" + +dependencies: + # flutter.dev + animations: ^2.0.11 + # dart.dev + async: ^2.11.0 + # bbflight.com + # Note: No web support + background_downloader: ^8.5.6 + # dart.dev + convert: ^3.1.1 + # flutter.dev + cross_file: ^0.3.4+2 + # dart.dev + crypto: ^3.0.5 + # fluttercommunity.dev + device_info_plus: ^11.1.0 + # unverified uploader + dotted_border: ^2.1.0 + # simonbinder.eu + drift: ^2.22.0 + # miguelruivo.com + file_picker: ^8.1.2 + # dart.dev + fixnum: ^1.1.0 + flutter: + sdk: flutter + # fluttercandies + flutter_image_compress: ^2.3.0 + # jasonrai.ca + flutter_keyboard_visibility: ^6.0.0 + flutter_inappwebview: ^6.1.5 + # dexterx.dev + flutter_local_notifications: ^18.0.0 + # Flutter + flutter_localizations: + sdk: flutter + # dash-overflow.net + flutter_riverpod: ^2.5.1 + flutter_rust_bridge: ^2.5.0 + flutter_secure_storage: ^9.2.2 + # dnfield.dev + flutter_svg: ^2.0.10+1 + # midoridesign.studio + gal: ^2.3.0 + # material.io + google_fonts: ^6.2.1 + # dart.dev + http: ^1.2.2 + # loki3d.com + # Don't resize GIF images because the "image" package + # is buggy for resizing GIF images. + # e.g. https://github.com/brendan-duncan/image/issues/588 + # TODO: waiting for them to be fixed. + image: ^4.2.0 + # flutter.dev + image_picker: ^1.1.2 + intl: any + # lamnhan.dev + isolate_manager: ^5.6.1 + # tools.dart.dev + json_rpc_2: ^3.0.2 + markdown: ^7.2.2 + # hiveright.tech + # TODO: Wait for Flutter official solution: https://github.com/flutter/flutter/issues/102560 + # We need this dependency to support icon weight. + material_symbols_icons: ^4.2800.2 + # media-kit.dev + media_kit: ^1.1.10+1 + media_kit_libs_audio: ^1.0.5 + media_kit_libs_video: ^1.0.5 + media_kit_video: ^1.2.5 +# media_kit_libs_android_video: any +# media_kit_libs_ios_video: any +# media_kit_libs_linux: any +# media_kit_libs_macos_video: any +# media_kit_libs_windows_video: any + # buildtoapp.com + open_file: ^3.5.7 + # fluttercommunity.dev + package_info_plus: ^8.0.2 + # dart.dev + path: ^1.9.0 + # flutter.dev + path_provider: ^2.1.4 + # google.dev + protobuf: ^3.1.0 + # tools.dart.dev + pub_semver: ^2.1.4 + responsive_framework: ^1.5.1 + # dash-overflow.net + riverpod_annotation: ^2.3.5 + rust_lib_turms_chat_demo: + path: rust_builder + # tools.dart.dev + shelf_web_socket: ^2.0.0 + # simonbinder.eu + sqlite3_flutter_libs: ^0.5.24 + # nativeshell.dev + super_clipboard: ^0.9.0-dev.3 + # nativeshell.dev + # Note: The Flutter team recommends "super_drag_and_drop". + super_drag_and_drop: ^0.9.0-dev.3 + # nativeshell.dev + super_hot_key: ^0.9.0-dev.3 + # leanflutter.dev + # TODO: Wait for Flutter official solution: https://github.com/flutter/flutter/issues/81644 + tray_manager: ^0.3.0 + turms_client_dart: + path: ../turms-client-dart/ + universal_html: ^2.2.4 + url_launcher: ^6.3.0 + # flutter.dev + video_player: ^2.9.1 + # media-kit.dev + video_player_media_kit: ^1.0.5 + visibility_detector: ^0.4.0+2 + web_socket_channel: ^3.0.1 + # TODO: Wait for Flutter official solution: https://github.com/flutter/flutter/issues/31373 + window_manager: ^0.4.2 + windows_notification: ^1.3.0 + +dev_dependencies: + build_runner: ^2.4.13 + # invertase.io + custom_lint: ^0.7.0 + drift_dev: ^2.22.0 + flutter_gen_runner: ^5.7.0 + flutter_launcher_icons: ^0.14.1 + flutter_lints: ^5.0.0 + flutter_platform_alert: ^0.6.1 + flutter_test: + sdk: flutter + # matcher: ^0.12.16 + # mockito: ^5.4.2 + # Flutter + # TODO: Wait for Linux support: https://github.com/flutter/flutter/issues/73740 + integration_test: + sdk: flutter + riverpod_lint: ^2.3.13 + +flutter: + # Ensures that the Material Icons font is included with our application + uses-material-design: true + # Used to generate l10n files + generate: true + assets: +# - assets/fonts/ + - assets/images/ +flutter_gen: + output: lib/infra/assets/ \ No newline at end of file diff --git a/turms-chat-demo-flutter/rust/.gitignore b/turms-chat-demo-flutter/rust/.gitignore new file mode 100644 index 0000000000..ea8c4bf7f3 --- /dev/null +++ b/turms-chat-demo-flutter/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/turms-chat-demo-flutter/rust/Cargo.lock b/turms-chat-demo-flutter/rust/Cargo.lock new file mode 100644 index 0000000000..f3947aa12c --- /dev/null +++ b/turms-chat-demo-flutter/rust/Cargo.lock @@ -0,0 +1,1940 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned-vec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" + +[[package]] +name = "allo-isolate" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f67642eb6773fb42a95dd3b348c305ee18dee6642274c6b412d67e985e3befc" +dependencies = [ + "anyhow", + "atomic", + "backtrace", +] + +[[package]] +name = "android_log-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ecc8056bf6ab9892dcd53216c83d1597487d7dacac16c8df6b877d127df9937" + +[[package]] +name = "android_logger" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c494134f746c14dc653a35a4ea5aca24ac368529da5370ecf41fe0341c35772f" +dependencies = [ + "android_log-sys", + "env_logger", + "log", + "once_cell", +] + +[[package]] +name = "anyhow" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "av1-grain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6678909d8c5d46a42abcf571271e15fdbc0a225e3646cf23762cd415046c78bf" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e335041290c43101ca215eed6f43ec437eb5a42125573f600fc3fa42b9bddd62" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "bit_field" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "build-target" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b" + +[[package]] +name = "built" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c360505aed52b7ec96a3636c3f039d99103c37d1d9b4f7a8c743d3ea9ffcd03b" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytemuck" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b37c88a63ffd85d15b406896cc343916d7cf57838a847b3a6f2ca5d39a5695a" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dart-sys-fork" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "933dafff26172b719bb9695dd3715a1e7792f62dcdc8a5d4c740db7e0fedee8b" +dependencies = [ + "cc", +] + +[[package]] +name = "dashmap" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e77a43b28d0668df09411cb0bc9a8c2adc40f9a048afe863e05fd43251e8e39c" +dependencies = [ + "cfg-if", + "num_cpus", +] + +[[package]] +name = "delegate-attr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51aac4c99b2e6775164b412ea33ae8441b2fde2dbf05a20bc0052a63d08c475b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "exr" +version = "1.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fdeflate" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c6f4c64c1d33a3111c4466f7365ebdcc37c5bd1ea0d62aae2e3d722aacbedb" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flutter_rust_bridge" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93b95a1b4f20b8c037535bcda990abf0ae2bd94c93e27ebbbe00633322bc1561" +dependencies = [ + "allo-isolate", + "android_logger", + "anyhow", + "build-target", + "bytemuck", + "byteorder", + "console_error_panic_hook", + "dart-sys-fork", + "delegate-attr", + "flutter_rust_bridge_macros", + "futures", + "js-sys", + "lazy_static", + "log", + "oslog", + "portable-atomic", + "threadpool", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "flutter_rust_bridge_macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fafd532ccfcce8ef23e858fe07303ff572e8b302be6ec0b0f38ca6eb319206dc" +dependencies = [ + "hex", + "md-5", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gif" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "icu_collator" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d370371887d31d56f361c3eaa15743e54f13bc677059c9191c77e099ed6966b2" +dependencies = [ + "displaydoc", + "icu_collator_data", + "icu_collections", + "icu_locid_transform", + "icu_normalizer", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_collator_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee3f88741364b7d6269cce6827a3e6a8a2cf408a78f766c9224ab479d5e4ae5" + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "image" +version = "0.25.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e031e8e3d94711a9ccb5d6ea357439ef3dcbed361798bd4071dc4d9793fbe22f" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" + +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" + +[[package]] +name = "js-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" + +[[package]] +name = "libc" +version = "0.2.165" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb4d3d38eab6c5239a362fa8bae48c03baf980a6e7079f063942d563ef3533e" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b9569d2f74e257076d8c6bfa73fb505b46b851e51ddaecc825944aa3bed17fa" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.36.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "oslog" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8343ce955f18e7e68c0207dd0ea776ec453035685395ababd2ea651c569728b3" +dependencies = [ + "cc", + "dashmap", + "log", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "png" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f9d46a34a05a6a57566bc2bfae066ef07585a6e3fa30fbbdff5936380623f0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65f2e60fbf1063868558d69c6beacf412dc755f9fc020f514b7955fc914fe30" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "system-deps", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2413fd96bd0ea5cdeeb37eaf446a22e6ed7b981d792828721e74ded1980a45c6" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rgb" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" + +[[package]] +name = "rust_lib_turms_chat_demo" +version = "0.1.0" +dependencies = [ + "flutter_rust_bridge", + "icu_collator", + "icu_locid", + "image", + "sysinfo", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "serde" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "syn" +version = "2.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" +dependencies = [ + "backtrace", + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "v_frame" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f32aaa24bacd11e488aa9ba66369c7cd514885742c9fe08cfe85884db3e92b" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + +[[package]] +name = "web-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16099418600b4d8f028622f73ff6e3deaabdff330fb9a2a131dea781ee8b0768" +dependencies = [ + "zune-core", +] diff --git a/turms-chat-demo-flutter/rust/Cargo.toml b/turms-chat-demo-flutter/rust/Cargo.toml new file mode 100644 index 0000000000..f91116f439 --- /dev/null +++ b/turms-chat-demo-flutter/rust/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "rust_lib_turms_chat_demo" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "staticlib"] + +[dependencies] +flutter_rust_bridge = "=2.6.0" +image = "0.25.5" +sysinfo = "0.32.0" +icu_collator = "1.5.0" +icu_locid = "1.5.0" \ No newline at end of file diff --git a/turms-chat-demo-flutter/rust/src/api/app.rs b/turms-chat-demo-flutter/rust/src/api/app.rs new file mode 100644 index 0000000000..abec4a9d4d --- /dev/null +++ b/turms-chat-demo-flutter/rust/src/api/app.rs @@ -0,0 +1,4 @@ +#[flutter_rust_bridge::frb(init)] +pub fn init_app() { + flutter_rust_bridge::setup_default_user_utils(); +} \ No newline at end of file diff --git a/turms-chat-demo-flutter/rust/src/api/icu.rs b/turms-chat-demo-flutter/rust/src/api/icu.rs new file mode 100644 index 0000000000..4d368c8812 --- /dev/null +++ b/turms-chat-demo-flutter/rust/src/api/icu.rs @@ -0,0 +1,87 @@ +use icu_collator::{Collator, CollatorOptions, Strength}; +use icu_locid::Locale; +use std::cell::RefCell; + +struct CollatorInfo { + locale: String, + collator: Collator, +} + +thread_local! { + static CACHED_COLLATOR_INFO: RefCell> = RefCell::new(None); +} + +fn init_collator_info(locale: &str) -> Option { + match locale + .parse::() { + Ok(target_locale) => { + let mut options = CollatorOptions::new(); + options.strength = Some(Strength::Tertiary); + match Collator::try_new(&target_locale.into(), options) { + Ok(collator) => { + CACHED_COLLATOR_INFO.replace(Some(CollatorInfo { + locale: locale.to_string(), + collator, + })); + CACHED_COLLATOR_INFO.take() + } + Err(_) => { + None + } + } + } + Err(_) => { + None + } + } +} + +fn get_locale_collator(locale: &str) -> Option { + let option = CACHED_COLLATOR_INFO.take(); + match option { + Some(ref collator_info) => { + if collator_info.locale == locale { + return option; + } + init_collator_info(locale) + } + None => { + init_collator_info(locale) + } + } +} + +#[flutter_rust_bridge::frb(sync)] +pub fn compare_strings(locale: &str, s1: &str, s2: &str) -> Option { + Some(get_locale_collator(locale)?.collator.compare(s1, s2) as i8) +} + +#[flutter_rust_bridge::frb(sync)] +/// TODO: Use `strings: &[&str]` when supported. +pub fn compare_string_vec(locale: &str, strings: Vec) -> Option> { + match get_locale_collator(&locale) { + None => { + None + } + Some(collator) => { + let mut indexes: Vec = (0..strings.len()).collect(); + indexes.sort_by(|a, b| { + collator.collator.compare(&strings[*a], &strings[*b]) + }); + Some(indexes.iter().map(|i| *i as u16).collect()) + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::icu::compare_string_vec; + + #[test] + fn test_compare_string_vec() { + let vec1 = vec!["Murmurs of Earth", "James Chen", "窦唯", "Nina Simone"] + .iter().map(|s| s.to_string()).collect(); + let vec2 = compare_string_vec("zh", vec1); + println!("{:?}", vec2); + } +} \ No newline at end of file diff --git a/turms-chat-demo-flutter/rust/src/api/image.rs b/turms-chat-demo-flutter/rust/src/api/image.rs new file mode 100644 index 0000000000..c3b6444c8f --- /dev/null +++ b/turms-chat-demo-flutter/rust/src/api/image.rs @@ -0,0 +1,108 @@ +use image::codecs::gif; +use image::codecs::gif::{GifDecoder, GifEncoder}; +use image::{imageops, AnimationDecoder, Frame, ImageError, ImageResult}; +use std::fs::File; +use std::io::BufReader; + +pub struct ResizeResult { + pub resized: bool, + pub error_type: Option, +} + +pub enum ResizeError { + Decoding, + Parameter, + Limits, + Unsupported, + IoError, +} + +pub fn resize(input_path: &str, output_path: &str, width: u32, height: u32) -> ResizeResult { + let result = resize0(input_path, output_path, width, height); + match result { + Ok(resized) => { + ResizeResult { + resized, + error_type: None, + } + } + Err(e) => { + ResizeResult { + resized: false, + error_type: Some(match e { + ImageError::Decoding(_) => { + ResizeError::Decoding + } + ImageError::Encoding(_) => { + panic!("Encoding error") + } + ImageError::Parameter(_) => { + ResizeError::Parameter + } + ImageError::Limits(_) => { + ResizeError::Limits + } + ImageError::Unsupported(_) => { + ResizeError::Unsupported + } + ImageError::IoError(_) => { + ResizeError::IoError + } + }), + } + } + } +} + +fn should_resize(width: u32, height: u32, img_width: u32, img_height: u32) -> bool { + img_width > width || img_height > height +} + +fn resize0(input_path: &str, output_path: &str, width: u32, height: u32) -> ImageResult { + // TODO: webp + if input_path.ends_with(".gif") { + return resize_gif(&input_path, &output_path, width, height); + } + let img = image::open(input_path)?; + if should_resize(width, height, img.width(), img.height()) { + let thumb = img.thumbnail(width, height); + thumb.save(output_path)?; + Ok(true) + } else { + Ok(false) + } +} + +fn resize_gif(input_path: &str, output_path: &str, width: u32, height: u32) -> ImageResult { + match File::open(input_path) { + Ok(f) => { + let raw_frames = GifDecoder::new(BufReader::new(f))?.into_frames(); + if let Ok(frames) = raw_frames.collect_frames() { + if frames.is_empty() { + return Ok(false); + } + let first_frame = frames.first().unwrap().buffer(); + if !should_resize(width, height, first_frame.width(), first_frame.height()) { + return Ok(false); + } + let resized_frames = frames.iter().map(|frame| Frame::new(imageops::thumbnail(&frame.buffer().clone(), width, height))); + match File::create(output_path) { + Ok(gif_out) => { + let mut encoder = GifEncoder::new(gif_out); + encoder.set_repeat(gif::Repeat::Infinite)?; + encoder.encode_frames(resized_frames)?; + Ok(true) + } + Err(e) => { + Err(ImageError::IoError(e)) + } + } + } else { + Ok(false) + } + } + Err(e) => { + Err(ImageError::IoError(e)) + } + } +} \ No newline at end of file diff --git a/turms-chat-demo-flutter/rust/src/api/mod.rs b/turms-chat-demo-flutter/rust/src/api/mod.rs new file mode 100644 index 0000000000..ca675e57f7 --- /dev/null +++ b/turms-chat-demo-flutter/rust/src/api/mod.rs @@ -0,0 +1,4 @@ +pub mod app; +pub mod icu; +pub mod image; +pub mod system; \ No newline at end of file diff --git a/turms-chat-demo-flutter/rust/src/api/system.rs b/turms-chat-demo-flutter/rust/src/api/system.rs new file mode 100644 index 0000000000..9614958294 --- /dev/null +++ b/turms-chat-demo-flutter/rust/src/api/system.rs @@ -0,0 +1,38 @@ +use sysinfo::{Disks, Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + +pub struct DiskSpaceInfo { + pub path: String, + pub total: u64, + pub available: u64, +} + +#[flutter_rust_bridge::frb(sync)] +pub fn get_disk_space_infos() -> Vec { + Disks::new_with_refreshed_list().list().iter().map(|disk| { + return DiskSpaceInfo { + path: disk.mount_point().to_str().unwrap().to_string(), + total: disk.total_space(), + available: disk.available_space(), + }; + }) + .collect::>() +} + +#[flutter_rust_bridge::frb(sync)] +pub fn is_process_running(pid: u32) -> bool { + let mut s = System::new(); + s.refresh_processes_specifics(ProcessesToUpdate::All, true, ProcessRefreshKind::default()); + let pid_to_process = s.processes(); + pid_to_process.contains_key(&Pid::from_u32(pid)) +} + +#[cfg(test)] +mod tests { + use crate::api::system::is_process_running; + + #[test] + fn test_is_process_running() { + let is_running = is_process_running(std::process::id()); + assert_eq!(is_running, true); + } +} \ No newline at end of file diff --git a/turms-chat-demo-flutter/rust/src/frb_generated.rs b/turms-chat-demo-flutter/rust/src/frb_generated.rs new file mode 100644 index 0000000000..889d5fc3fc --- /dev/null +++ b/turms-chat-demo-flutter/rust/src/frb_generated.rs @@ -0,0 +1,753 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.6.0. + +#![allow( + non_camel_case_types, + unused, + non_snake_case, + clippy::needless_return, + clippy::redundant_closure_call, + clippy::redundant_closure, + clippy::useless_conversion, + clippy::unit_arg, + clippy::unused_unit, + clippy::double_parens, + clippy::let_and_return, + clippy::too_many_arguments, + clippy::match_single_binding, + clippy::clone_on_copy, + clippy::let_unit_value, + clippy::deref_addrof, + clippy::explicit_auto_deref, + clippy::borrow_deref_ref, + clippy::needless_borrow +)] + +// Section: imports + +use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt}; +use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; +use flutter_rust_bridge::{Handler, IntoIntoDart}; + +// Section: boilerplate + +flutter_rust_bridge::frb_generated_boilerplate!( + default_stream_sink_codec = SseCodec, + default_rust_opaque = RustOpaqueMoi, + default_rust_auto_opaque = RustAutoOpaqueMoi, +); +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.6.0"; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1538041608; + +// Section: executor + +flutter_rust_bridge::frb_generated_default_handler!(); + +// Section: wire_funcs + +fn wire__crate__api__icu__compare_string_vec_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "compare_string_vec", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + 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 api_locale = ::sse_decode(&mut deserializer); + let api_strings = >::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::icu::compare_string_vec( + &api_locale, + api_strings, + ))?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__icu__compare_strings_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "compare_strings", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + 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 api_locale = ::sse_decode(&mut deserializer); + let api_s1 = ::sse_decode(&mut deserializer); + let api_s2 = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::icu::compare_strings( + &api_locale, + &api_s1, + &api_s2, + ))?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__system__get_disk_space_infos_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_disk_space_infos", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + 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); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::system::get_disk_space_infos())?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__app__init_app_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "init_app", + 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 mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::app::init_app(); + })?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__system__is_process_running_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "is_process_running", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + 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 api_pid = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = + Result::<_, ()>::Ok(crate::api::system::is_process_running(api_pid))?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__image__resize_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "resize", + 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 mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_input_path = ::sse_decode(&mut deserializer); + let api_output_path = ::sse_decode(&mut deserializer); + let api_width = ::sse_decode(&mut deserializer); + let api_height = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::image::resize( + &api_input_path, + &api_output_path, + api_width, + api_height, + ))?; + Ok(output_ok) + })()) + } + }, + ) +} + +// Section: dart2rust + +impl SseDecode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = >::sse_decode(deserializer); + return String::from_utf8(inner).unwrap(); + } +} + +impl SseDecode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() != 0 + } +} + +impl SseDecode for crate::api::system::DiskSpaceInfo { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_path = ::sse_decode(deserializer); + let mut var_total = ::sse_decode(deserializer); + let mut var_available = ::sse_decode(deserializer); + return crate::api::system::DiskSpaceInfo { + path: var_path, + total: var_total, + available: var_available, + }; + } +} + +impl SseDecode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i32::().unwrap() + } +} + +impl SseDecode for i8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i8().unwrap() + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(::sse_decode( + deserializer, + )); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + +impl SseDecode for Option> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(>::sse_decode(deserializer)); + } else { + return None; + } + } +} + +impl SseDecode for crate::api::image::ResizeError { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return match inner { + 0 => crate::api::image::ResizeError::Decoding, + 1 => crate::api::image::ResizeError::Parameter, + 2 => crate::api::image::ResizeError::Limits, + 3 => crate::api::image::ResizeError::Unsupported, + 4 => crate::api::image::ResizeError::IoError, + _ => unreachable!("Invalid variant for ResizeError: {}", inner), + }; + } +} + +impl SseDecode for crate::api::image::ResizeResult { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_resized = ::sse_decode(deserializer); + let mut var_errorType = >::sse_decode(deserializer); + return crate::api::image::ResizeResult { + resized: var_resized, + error_type: var_errorType, + }; + } +} + +impl SseDecode for u16 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u16::().unwrap() + } +} + +impl SseDecode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u32::().unwrap() + } +} + +impl SseDecode for u64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u64::().unwrap() + } +} + +impl SseDecode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() + } +} + +impl SseDecode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {} +} + +fn pde_ffi_dispatcher_primary_impl( + func_id: i32, + port: flutter_rust_bridge::for_generated::MessagePort, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 4 => wire__crate__api__app__init_app_impl(port, ptr, rust_vec_len, data_len), + 6 => wire__crate__api__image__resize_impl(port, ptr, rust_vec_len, data_len), + _ => unreachable!(), + } +} + +fn pde_ffi_dispatcher_sync_impl( + func_id: i32, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 1 => wire__crate__api__icu__compare_string_vec_impl(ptr, rust_vec_len, data_len), + 2 => wire__crate__api__icu__compare_strings_impl(ptr, rust_vec_len, data_len), + 3 => wire__crate__api__system__get_disk_space_infos_impl(ptr, rust_vec_len, data_len), + 5 => wire__crate__api__system__is_process_running_impl(ptr, rust_vec_len, data_len), + _ => unreachable!(), + } +} + +// Section: rust2dart + +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::system::DiskSpaceInfo { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.path.into_into_dart().into_dart(), + self.total.into_into_dart().into_dart(), + self.available.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::system::DiskSpaceInfo +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::system::DiskSpaceInfo +{ + fn into_into_dart(self) -> crate::api::system::DiskSpaceInfo { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::image::ResizeError { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::Decoding => 0.into_dart(), + Self::Parameter => 1.into_dart(), + Self::Limits => 2.into_dart(), + Self::Unsupported => 3.into_dart(), + Self::IoError => 4.into_dart(), + _ => unreachable!(), + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::image::ResizeError +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::image::ResizeError +{ + fn into_into_dart(self) -> crate::api::image::ResizeError { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::image::ResizeResult { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.resized.into_into_dart().into_dart(), + self.error_type.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::image::ResizeResult +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::image::ResizeResult +{ + fn into_into_dart(self) -> crate::api::image::ResizeResult { + self + } +} + +impl SseEncode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.into_bytes(), serializer); + } +} + +impl SseEncode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self as _).unwrap(); + } +} + +impl SseEncode for crate::api::system::DiskSpaceInfo { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.path, serializer); + ::sse_encode(self.total, serializer); + ::sse_encode(self.available, serializer); + } +} + +impl SseEncode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i32::(self).unwrap(); + } +} + +impl SseEncode for i8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i8(self).unwrap(); + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + +impl SseEncode for Option> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + >::sse_encode(value, serializer); + } + } +} + +impl SseEncode for crate::api::image::ResizeError { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode( + match self { + crate::api::image::ResizeError::Decoding => 0, + crate::api::image::ResizeError::Parameter => 1, + crate::api::image::ResizeError::Limits => 2, + crate::api::image::ResizeError::Unsupported => 3, + crate::api::image::ResizeError::IoError => 4, + _ => { + unimplemented!(""); + } + }, + serializer, + ); + } +} + +impl SseEncode for crate::api::image::ResizeResult { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.resized, serializer); + >::sse_encode(self.error_type, serializer); + } +} + +impl SseEncode for u16 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u16::(self).unwrap(); + } +} + +impl SseEncode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u32::(self).unwrap(); + } +} + +impl SseEncode for u64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u64::(self).unwrap(); + } +} + +impl SseEncode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self).unwrap(); + } +} + +impl SseEncode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {} +} + +#[cfg(not(target_family = "wasm"))] +mod io { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.6.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_io!(); +} +#[cfg(not(target_family = "wasm"))] +pub use io::*; + +/// cbindgen:ignore +#[cfg(target_family = "wasm")] +mod web { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.6.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::wasm_bindgen; + use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_web!(); +} +#[cfg(target_family = "wasm")] +pub use web::*; diff --git a/turms-chat-demo-flutter/rust/src/lib.rs b/turms-chat-demo-flutter/rust/src/lib.rs new file mode 100644 index 0000000000..cbb071f8bf --- /dev/null +++ b/turms-chat-demo-flutter/rust/src/lib.rs @@ -0,0 +1,2 @@ +pub mod api; +mod frb_generated; diff --git a/turms-chat-demo-flutter/rust_builder/.gitignore b/turms-chat-demo-flutter/rust_builder/.gitignore new file mode 100644 index 0000000000..ac5aa9893e --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ diff --git a/turms-chat-demo-flutter/rust_builder/README.md b/turms-chat-demo-flutter/rust_builder/README.md new file mode 100644 index 0000000000..922615f9c1 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/README.md @@ -0,0 +1 @@ +Please ignore this folder, which is just glue to build Rust with Flutter. \ No newline at end of file diff --git a/turms-chat-demo-flutter/rust_builder/android/.gitignore b/turms-chat-demo-flutter/rust_builder/android/.gitignore new file mode 100644 index 0000000000..161bdcdaf8 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/turms-chat-demo-flutter/rust_builder/android/build.gradle b/turms-chat-demo-flutter/rust_builder/android/build.gradle new file mode 100644 index 0000000000..5aa5a031fc --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/android/build.gradle @@ -0,0 +1,56 @@ +// The Android Gradle Plugin builds the native code with the Android NDK. + +group 'com.flutter_rust_bridge.rust_lib_turms_chat_demo' +version '1.0' + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + // The Android Gradle Plugin knows how to build native code with the NDK. + classpath 'com.android.tools.build:gradle:7.3.0' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' + +android { + if (project.android.hasProperty("namespace")) { + namespace 'com.flutter_rust_bridge.rust_lib_turms_chat_demo' + } + + // Bumping the plugin compileSdkVersion requires all clients of this plugin + // to bump the version in their app. + compileSdkVersion 33 + + // Use the NDK version + // declared in /android/app/build.gradle file of the Flutter project. + // Replace it with a version number if this plugin requires a specfic NDK version. + // (e.g. ndkVersion "23.1.7779620") + ndkVersion android.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion 19 + } +} + +apply from: "../cargokit/gradle/plugin.gradle" +cargokit { + manifestDir = "../../rust" + libname = "rust_lib_turms_chat_demo" +} diff --git a/turms-chat-demo-flutter/rust_builder/android/settings.gradle b/turms-chat-demo-flutter/rust_builder/android/settings.gradle new file mode 100644 index 0000000000..9c6a972ead --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'rust_lib_turms_chat_demo' diff --git a/turms-chat-demo-flutter/rust_builder/android/src/main/AndroidManifest.xml b/turms-chat-demo-flutter/rust_builder/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..57a939d84e --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/.gitignore b/turms-chat-demo-flutter/rust_builder/cargokit/.gitignore new file mode 100644 index 0000000000..cf7bb868c0 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/.gitignore @@ -0,0 +1,4 @@ +target +.dart_tool +*.iml +!pubspec.lock diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/LICENSE b/turms-chat-demo-flutter/rust_builder/cargokit/LICENSE new file mode 100644 index 0000000000..d33a5fea52 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/LICENSE @@ -0,0 +1,42 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +Copyright 2022 Matej Knopp + +================================================================================ + +MIT LICENSE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + +APACHE LICENSE, VERSION 2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/README b/turms-chat-demo-flutter/rust_builder/cargokit/README new file mode 100644 index 0000000000..398474dbc8 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/README @@ -0,0 +1,11 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +Experimental repository to provide glue for seamlessly integrating cargo build +with flutter plugins and packages. + +See https://matejknopp.com/post/flutter_plugin_in_rust_with_no_prebuilt_binaries/ +for a tutorial on how to use Cargokit. + +Example plugin available at https://github.com/irondash/hello_rust_ffi_plugin. + diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_pod.sh b/turms-chat-demo-flutter/rust_builder/cargokit/build_pod.sh new file mode 100644 index 0000000000..ed0e0d987d --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_pod.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -e + +BASEDIR=$(dirname "$0") + +# Workaround for https://github.com/dart-lang/pub/issues/4010 +BASEDIR=$(cd "$BASEDIR" ; pwd -P) + +# Remove XCode SDK from path. Otherwise this breaks tool compilation when building iOS project +NEW_PATH=`echo $PATH | tr ":" "\n" | grep -v "Contents/Developer/" | tr "\n" ":"` + +export PATH=${NEW_PATH%?} # remove trailing : + +env + +# Platform name (macosx, iphoneos, iphonesimulator) +export CARGOKIT_DARWIN_PLATFORM_NAME=$PLATFORM_NAME + +# Arctive architectures (arm64, armv7, x86_64), space separated. +export CARGOKIT_DARWIN_ARCHS=$ARCHS + +# Current build configuration (Debug, Release) +export CARGOKIT_CONFIGURATION=$CONFIGURATION + +# Path to directory containing Cargo.toml. +export CARGOKIT_MANIFEST_DIR=$PODS_TARGET_SRCROOT/$1 + +# Temporary directory for build artifacts. +export CARGOKIT_TARGET_TEMP_DIR=$TARGET_TEMP_DIR + +# Output directory for final artifacts. +export CARGOKIT_OUTPUT_DIR=$PODS_CONFIGURATION_BUILD_DIR/$PRODUCT_NAME + +# Directory to store built tool artifacts. +export CARGOKIT_TOOL_TEMP_DIR=$TARGET_TEMP_DIR/build_tool + +# Directory inside root project. Not necessarily the top level directory of root project. +export CARGOKIT_ROOT_PROJECT_DIR=$SRCROOT + +FLUTTER_EXPORT_BUILD_ENVIRONMENT=( + "$PODS_ROOT/../Flutter/ephemeral/flutter_export_environment.sh" # macOS + "$PODS_ROOT/../Flutter/flutter_export_environment.sh" # iOS +) + +for path in "${FLUTTER_EXPORT_BUILD_ENVIRONMENT[@]}" +do + if [[ -f "$path" ]]; then + source "$path" + fi +done + +sh "$BASEDIR/run_build_tool.sh" build-pod "$@" + +# Make a symlink from built framework to phony file, which will be used as input to +# build script. This should force rebuild (podspec currently doesn't support alwaysOutOfDate +# attribute on custom build phase) +ln -fs "$OBJROOT/XCBuildData/build.db" "${BUILT_PRODUCTS_DIR}/cargokit_phony" +ln -fs "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}" "${BUILT_PRODUCTS_DIR}/cargokit_phony_out" diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/README.md b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/README.md new file mode 100644 index 0000000000..a878c27964 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/README.md @@ -0,0 +1,5 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +A sample command-line application with an entrypoint in `bin/`, library code +in `lib/`, and example unit test in `test/`. diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/analysis_options.yaml b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/analysis_options.yaml new file mode 100644 index 0000000000..0e16a8b092 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/analysis_options.yaml @@ -0,0 +1,34 @@ +# This is copied from Cargokit (which is the official way to use it currently) +# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +linter: + rules: + - prefer_relative_imports + - directives_ordering + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/bin/build_tool.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/bin/build_tool.dart new file mode 100644 index 0000000000..268eb524dc --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/bin/build_tool.dart @@ -0,0 +1,8 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'package:build_tool/build_tool.dart' as build_tool; + +void main(List arguments) { + build_tool.runMain(arguments); +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/build_tool.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/build_tool.dart new file mode 100644 index 0000000000..7c1bb750a4 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/build_tool.dart @@ -0,0 +1,8 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'src/build_tool.dart' as build_tool; + +Future runMain(List args) async { + return build_tool.runMain(args); +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/android_environment.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/android_environment.dart new file mode 100644 index 0000000000..15fc9eedac --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/android_environment.dart @@ -0,0 +1,195 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; +import 'dart:isolate'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as path; +import 'package:version/version.dart'; + +import 'target.dart'; +import 'util.dart'; + +class AndroidEnvironment { + AndroidEnvironment({ + required this.sdkPath, + required this.ndkVersion, + required this.minSdkVersion, + required this.targetTempDir, + required this.target, + }); + + static void clangLinkerWrapper(List args) { + final clang = Platform.environment['_CARGOKIT_NDK_LINK_CLANG']; + if (clang == null) { + throw Exception( + "cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_CLANG env var"); + } + final target = Platform.environment['_CARGOKIT_NDK_LINK_TARGET']; + if (target == null) { + throw Exception( + "cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_TARGET env var"); + } + + runCommand(clang, [ + target, + ...args, + ]); + } + + /// Full path to Android SDK. + final String sdkPath; + + /// Full version of Android NDK. + final String ndkVersion; + + /// Minimum supported SDK version. + final int minSdkVersion; + + /// Target directory for build artifacts. + final String targetTempDir; + + /// Target being built. + final Target target; + + bool ndkIsInstalled() { + final ndkPath = path.join(sdkPath, 'ndk', ndkVersion); + final ndkPackageXml = File(path.join(ndkPath, 'package.xml')); + return ndkPackageXml.existsSync(); + } + + void installNdk({ + required String javaHome, + }) { + final sdkManagerExtension = Platform.isWindows ? '.bat' : ''; + final sdkManager = path.join( + sdkPath, + 'cmdline-tools', + 'latest', + 'bin', + 'sdkmanager$sdkManagerExtension', + ); + + log.info('Installing NDK $ndkVersion'); + runCommand(sdkManager, [ + '--install', + 'ndk;$ndkVersion', + ], environment: { + 'JAVA_HOME': javaHome, + }); + } + + Future> buildEnvironment() async { + final hostArch = Platform.isMacOS + ? "darwin-x86_64" + : (Platform.isLinux ? "linux-x86_64" : "windows-x86_64"); + + final ndkPath = path.join(sdkPath, 'ndk', ndkVersion); + final toolchainPath = path.join( + ndkPath, + 'toolchains', + 'llvm', + 'prebuilt', + hostArch, + 'bin', + ); + + final minSdkVersion = + math.max(target.androidMinSdkVersion!, this.minSdkVersion); + + final exe = Platform.isWindows ? '.exe' : ''; + + final arKey = 'AR_${target.rust}'; + final arValue = ['${target.rust}-ar', 'llvm-ar', 'llvm-ar.exe'] + .map((e) => path.join(toolchainPath, e)) + .firstWhereOrNull((element) => File(element).existsSync()); + if (arValue == null) { + throw Exception('Failed to find ar for $target in $toolchainPath'); + } + + final targetArg = '--target=${target.rust}$minSdkVersion'; + + final ccKey = 'CC_${target.rust}'; + final ccValue = path.join(toolchainPath, 'clang$exe'); + final cfFlagsKey = 'CFLAGS_${target.rust}'; + final cFlagsValue = targetArg; + + final cxxKey = 'CXX_${target.rust}'; + final cxxValue = path.join(toolchainPath, 'clang++$exe'); + final cxxFlagsKey = 'CXXFLAGS_${target.rust}'; + final cxxFlagsValue = targetArg; + + final linkerKey = + 'cargo_target_${target.rust.replaceAll('-', '_')}_linker'.toUpperCase(); + + final ranlibKey = 'RANLIB_${target.rust}'; + final ranlibValue = path.join(toolchainPath, 'llvm-ranlib$exe'); + + final ndkVersionParsed = Version.parse(ndkVersion); + final rustFlagsKey = 'CARGO_ENCODED_RUSTFLAGS'; + final rustFlagsValue = _libGccWorkaround(targetTempDir, ndkVersionParsed); + + final runRustTool = + Platform.isWindows ? 'run_build_tool.cmd' : 'run_build_tool.sh'; + + final packagePath = (await Isolate.resolvePackageUri( + Uri.parse('package:build_tool/buildtool.dart')))! + .toFilePath(); + final selfPath = path.canonicalize(path.join( + packagePath, + '..', + '..', + '..', + runRustTool, + )); + + // Make sure that run_build_tool is working properly even initially launched directly + // through dart run. + final toolTempDir = + Platform.environment['CARGOKIT_TOOL_TEMP_DIR'] ?? targetTempDir; + + return { + arKey: arValue, + ccKey: ccValue, + cfFlagsKey: cFlagsValue, + cxxKey: cxxValue, + cxxFlagsKey: cxxFlagsValue, + ranlibKey: ranlibValue, + rustFlagsKey: rustFlagsValue, + linkerKey: selfPath, + // Recognized by main() so we know when we're acting as a wrapper + '_CARGOKIT_NDK_LINK_TARGET': targetArg, + '_CARGOKIT_NDK_LINK_CLANG': ccValue, + 'CARGOKIT_TOOL_TEMP_DIR': toolTempDir, + }; + } + + // Workaround for libgcc missing in NDK23, inspired by cargo-ndk + String _libGccWorkaround(String buildDir, Version ndkVersion) { + final workaroundDir = path.join( + buildDir, + 'cargokit', + 'libgcc_workaround', + '${ndkVersion.major}', + ); + Directory(workaroundDir).createSync(recursive: true); + if (ndkVersion.major >= 23) { + File(path.join(workaroundDir, 'libgcc.a')) + .writeAsStringSync('INPUT(-lunwind)'); + } else { + // Other way around, untested, forward libgcc.a from libunwind once Rust + // gets updated for NDK23+. + File(path.join(workaroundDir, 'libunwind.a')) + .writeAsStringSync('INPUT(-lgcc)'); + } + + var rustFlags = Platform.environment['CARGO_ENCODED_RUSTFLAGS'] ?? ''; + if (rustFlags.isNotEmpty) { + rustFlags = '$rustFlags\x1f'; + } + rustFlags = '$rustFlags-L\x1f$workaroundDir'; + return rustFlags; + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/artifacts_provider.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/artifacts_provider.dart new file mode 100644 index 0000000000..e608cece73 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/artifacts_provider.dart @@ -0,0 +1,266 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:http/http.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'builder.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'rustup.dart'; +import 'target.dart'; + +class Artifact { + /// File system location of the artifact. + final String path; + + /// Actual file name that the artifact should have in destination folder. + final String finalFileName; + + AritifactType get type { + if (finalFileName.endsWith('.dll') || + finalFileName.endsWith('.dll.lib') || + finalFileName.endsWith('.pdb') || + finalFileName.endsWith('.so') || + finalFileName.endsWith('.dylib')) { + return AritifactType.dylib; + } else if (finalFileName.endsWith('.lib') || finalFileName.endsWith('.a')) { + return AritifactType.staticlib; + } else { + throw Exception('Unknown artifact type for $finalFileName'); + } + } + + Artifact({ + required this.path, + required this.finalFileName, + }); +} + +final _log = Logger('artifacts_provider'); + +class ArtifactProvider { + ArtifactProvider({ + required this.environment, + required this.userOptions, + }); + + final BuildEnvironment environment; + final CargokitUserOptions userOptions; + + Future>> getArtifacts(List targets) async { + final result = await _getPrecompiledArtifacts(targets); + + final pendingTargets = List.of(targets); + pendingTargets.removeWhere((element) => result.containsKey(element)); + + if (pendingTargets.isEmpty) { + return result; + } + + final rustup = Rustup(); + for (final target in targets) { + final builder = RustBuilder(target: target, environment: environment); + builder.prepare(rustup); + _log.info('Building ${environment.crateInfo.packageName} for $target'); + final targetDir = await builder.build(); + // For local build accept both static and dynamic libraries. + final artifactNames = { + ...getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + aritifactType: AritifactType.dylib, + remote: false, + ), + ...getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + aritifactType: AritifactType.staticlib, + remote: false, + ) + }; + final artifacts = artifactNames + .map((artifactName) => Artifact( + path: path.join(targetDir, artifactName), + finalFileName: artifactName, + )) + .where((element) => File(element.path).existsSync()) + .toList(); + result[target] = artifacts; + } + return result; + } + + Future>> _getPrecompiledArtifacts( + List targets) async { + if (userOptions.usePrecompiledBinaries == false) { + _log.info('Precompiled binaries are disabled'); + return {}; + } + if (environment.crateOptions.precompiledBinaries == null) { + _log.fine('Precompiled binaries not enabled for this crate'); + return {}; + } + + final start = Stopwatch()..start(); + final crateHash = CrateHash.compute(environment.manifestDir, + tempStorage: environment.targetTempDir); + _log.fine( + 'Computed crate hash $crateHash in ${start.elapsedMilliseconds}ms'); + + final downloadedArtifactsDir = + path.join(environment.targetTempDir, 'precompiled', crateHash); + Directory(downloadedArtifactsDir).createSync(recursive: true); + + final res = >{}; + + for (final target in targets) { + final requiredArtifacts = getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + remote: true, + ); + final artifactsForTarget = []; + + for (final artifact in requiredArtifacts) { + final fileName = PrecompileBinaries.fileName(target, artifact); + final downloadedPath = path.join(downloadedArtifactsDir, fileName); + if (!File(downloadedPath).existsSync()) { + final signatureFileName = + PrecompileBinaries.signatureFileName(target, artifact); + await _tryDownloadArtifacts( + crateHash: crateHash, + fileName: fileName, + signatureFileName: signatureFileName, + finalPath: downloadedPath, + ); + } + if (File(downloadedPath).existsSync()) { + artifactsForTarget.add(Artifact( + path: downloadedPath, + finalFileName: artifact, + )); + } else { + break; + } + } + + // Only provide complete set of artifacts. + if (artifactsForTarget.length == requiredArtifacts.length) { + _log.fine('Found precompiled artifacts for $target'); + res[target] = artifactsForTarget; + } + } + + return res; + } + + static Future _get(Uri url, {Map? headers}) async { + int attempt = 0; + const maxAttempts = 10; + while (true) { + try { + return await get(url, headers: headers); + } on SocketException catch (e) { + // Try to detect reset by peer error and retry. + if (attempt++ < maxAttempts && + (e.osError?.errorCode == 54 || e.osError?.errorCode == 10054)) { + _log.severe( + 'Failed to download $url: $e, attempt $attempt of $maxAttempts, will retry...'); + await Future.delayed(Duration(seconds: 1)); + continue; + } else { + rethrow; + } + } + } + } + + Future _tryDownloadArtifacts({ + required String crateHash, + required String fileName, + required String signatureFileName, + required String finalPath, + }) async { + final precompiledBinaries = environment.crateOptions.precompiledBinaries!; + final prefix = precompiledBinaries.uriPrefix; + final url = Uri.parse('$prefix$crateHash/$fileName'); + final signatureUrl = Uri.parse('$prefix$crateHash/$signatureFileName'); + _log.fine('Downloading signature from $signatureUrl'); + final signature = await _get(signatureUrl); + if (signature.statusCode == 404) { + _log.warning( + 'Precompiled binaries not available for crate hash $crateHash ($fileName)'); + return; + } + if (signature.statusCode != 200) { + _log.severe( + 'Failed to download signature $signatureUrl: status ${signature.statusCode}'); + return; + } + _log.fine('Downloading binary from $url'); + final res = await _get(url); + if (res.statusCode != 200) { + _log.severe('Failed to download binary $url: status ${res.statusCode}'); + return; + } + if (verify( + precompiledBinaries.publicKey, res.bodyBytes, signature.bodyBytes)) { + File(finalPath).writeAsBytesSync(res.bodyBytes); + } else { + _log.shout('Signature verification failed! Ignoring binary.'); + } + } +} + +enum AritifactType { + staticlib, + dylib, +} + +AritifactType artifactTypeForTarget(Target target) { + if (target.darwinPlatform != null) { + return AritifactType.staticlib; + } else { + return AritifactType.dylib; + } +} + +List getArtifactNames({ + required Target target, + required String libraryName, + required bool remote, + AritifactType? aritifactType, +}) { + aritifactType ??= artifactTypeForTarget(target); + if (target.darwinArch != null) { + if (aritifactType == AritifactType.staticlib) { + return ['lib$libraryName.a']; + } else { + return ['lib$libraryName.dylib']; + } + } else if (target.rust.contains('-windows-')) { + if (aritifactType == AritifactType.staticlib) { + return ['$libraryName.lib']; + } else { + return [ + '$libraryName.dll', + '$libraryName.dll.lib', + if (!remote) '$libraryName.pdb' + ]; + } + } else if (target.rust.contains('-linux-')) { + if (aritifactType == AritifactType.staticlib) { + return ['lib$libraryName.a']; + } else { + return ['lib$libraryName.so']; + } + } else { + throw Exception("Unsupported target: ${target.rust}"); + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/build_cmake.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/build_cmake.dart new file mode 100644 index 0000000000..6f3b2a4ec1 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/build_cmake.dart @@ -0,0 +1,40 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; + +class BuildCMake { + final CargokitUserOptions userOptions; + + BuildCMake({required this.userOptions}); + + Future build() async { + final targetPlatform = Environment.targetPlatform; + final target = Target.forFlutterName(Environment.targetPlatform); + if (target == null) { + throw Exception("Unknown target platform: $targetPlatform"); + } + + final environment = BuildEnvironment.fromEnvironment(isAndroid: false); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts([target]); + + final libs = artifacts[target]!; + + for (final lib in libs) { + if (lib.type == AritifactType.dylib) { + File(lib.path) + .copySync(path.join(Environment.outputDir, lib.finalFileName)); + } + } + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/build_gradle.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/build_gradle.dart new file mode 100644 index 0000000000..7e61fcbb7c --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/build_gradle.dart @@ -0,0 +1,49 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; + +final log = Logger('build_gradle'); + +class BuildGradle { + BuildGradle({required this.userOptions}); + + final CargokitUserOptions userOptions; + + Future build() async { + final targets = Environment.targetPlatforms.map((arch) { + final target = Target.forFlutterName(arch); + if (target == null) { + throw Exception( + "Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}"); + } + return target; + }).toList(); + + final environment = BuildEnvironment.fromEnvironment(isAndroid: true); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts(targets); + + for (final target in targets) { + final libs = artifacts[target]!; + final outputDir = path.join(Environment.outputDir, target.android!); + Directory(outputDir).createSync(recursive: true); + + for (final lib in libs) { + if (lib.type == AritifactType.dylib) { + File(lib.path).copySync(path.join(outputDir, lib.finalFileName)); + } + } + } + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/build_pod.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/build_pod.dart new file mode 100644 index 0000000000..8a9c0db5de --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/build_pod.dart @@ -0,0 +1,89 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; +import 'util.dart'; + +class BuildPod { + BuildPod({required this.userOptions}); + + final CargokitUserOptions userOptions; + + Future build() async { + final targets = Environment.darwinArchs.map((arch) { + final target = Target.forDarwin( + platformName: Environment.darwinPlatformName, darwinAarch: arch); + if (target == null) { + throw Exception( + "Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}"); + } + return target; + }).toList(); + + final environment = BuildEnvironment.fromEnvironment(isAndroid: false); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts(targets); + + void performLipo(String targetFile, Iterable sourceFiles) { + runCommand("lipo", [ + '-create', + ...sourceFiles, + '-output', + targetFile, + ]); + } + + final outputDir = Environment.outputDir; + + Directory(outputDir).createSync(recursive: true); + + final staticLibs = artifacts.values + .expand((element) => element) + .where((element) => element.type == AritifactType.staticlib) + .toList(); + final dynamicLibs = artifacts.values + .expand((element) => element) + .where((element) => element.type == AritifactType.dylib) + .toList(); + + final libName = environment.crateInfo.packageName; + + // If there is static lib, use it and link it with pod + if (staticLibs.isNotEmpty) { + final finalTargetFile = path.join(outputDir, "lib$libName.a"); + performLipo(finalTargetFile, staticLibs.map((e) => e.path)); + } else { + // Otherwise try to replace bundle dylib with our dylib + final bundlePaths = [ + '$libName.framework/Versions/A/$libName', + '$libName.framework/$libName', + ]; + + for (final bundlePath in bundlePaths) { + final targetFile = path.join(outputDir, bundlePath); + if (File(targetFile).existsSync()) { + performLipo(targetFile, dynamicLibs.map((e) => e.path)); + + // Replace absolute id with @rpath one so that it works properly + // when moved to Frameworks. + runCommand("install_name_tool", [ + '-id', + '@rpath/$bundlePath', + targetFile, + ]); + return; + } + } + throw Exception('Unable to find bundle for dynamic library'); + } + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/build_tool.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/build_tool.dart new file mode 100644 index 0000000000..c8f36981b5 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/build_tool.dart @@ -0,0 +1,271 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:github/github.dart'; +import 'package:hex/hex.dart'; +import 'package:logging/logging.dart'; + +import 'android_environment.dart'; +import 'build_cmake.dart'; +import 'build_gradle.dart'; +import 'build_pod.dart'; +import 'logging.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'target.dart'; +import 'util.dart'; +import 'verify_binaries.dart'; + +final log = Logger('build_tool'); + +abstract class BuildCommand extends Command { + Future runBuildCommand(CargokitUserOptions options); + + @override + Future run() async { + final options = CargokitUserOptions.load(); + + if (options.verboseLogging || + Platform.environment['CARGOKIT_VERBOSE'] == '1') { + enableVerboseLogging(); + } + + await runBuildCommand(options); + } +} + +class BuildPodCommand extends BuildCommand { + @override + final name = 'build-pod'; + + @override + final description = 'Build cocoa pod library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildPod(userOptions: options); + await build.build(); + } +} + +class BuildGradleCommand extends BuildCommand { + @override + final name = 'build-gradle'; + + @override + final description = 'Build android library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildGradle(userOptions: options); + await build.build(); + } +} + +class BuildCMakeCommand extends BuildCommand { + @override + final name = 'build-cmake'; + + @override + final description = 'Build CMake library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildCMake(userOptions: options); + await build.build(); + } +} + +class GenKeyCommand extends Command { + @override + final name = 'gen-key'; + + @override + final description = 'Generate key pair for signing precompiled binaries'; + + @override + void run() { + final kp = generateKey(); + final private = HEX.encode(kp.privateKey.bytes); + final public = HEX.encode(kp.publicKey.bytes); + print("Private Key: $private"); + print("Public Key: $public"); + } +} + +class PrecompileBinariesCommand extends Command { + PrecompileBinariesCommand() { + argParser + ..addOption( + 'repository', + mandatory: true, + help: 'Github repository slug in format owner/name', + ) + ..addOption( + 'manifest-dir', + mandatory: true, + help: 'Directory containing Cargo.toml', + ) + ..addMultiOption('target', + help: 'Rust target triple of artifact to build.\n' + 'Can be specified multiple times or omitted in which case\n' + 'all targets for current platform will be built.') + ..addOption( + 'android-sdk-location', + help: 'Location of Android SDK (if available)', + ) + ..addOption( + 'android-ndk-version', + help: 'Android NDK version (if available)', + ) + ..addOption( + 'android-min-sdk-version', + help: 'Android minimum rquired version (if available)', + ) + ..addOption( + 'temp-dir', + help: 'Directory to store temporary build artifacts', + ) + ..addFlag( + "verbose", + abbr: "v", + defaultsTo: false, + help: "Enable verbose logging", + ); + } + + @override + final name = 'precompile-binaries'; + + @override + final description = 'Prebuild and upload binaries\n' + 'Private key must be passed through PRIVATE_KEY environment variable. ' + 'Use gen_key through generate priave key.\n' + 'Github token must be passed as GITHUB_TOKEN environment variable.\n'; + + @override + Future run() async { + final verbose = argResults!['verbose'] as bool; + if (verbose) { + enableVerboseLogging(); + } + + final privateKeyString = Platform.environment['PRIVATE_KEY']; + if (privateKeyString == null) { + throw ArgumentError('Missing PRIVATE_KEY environment variable'); + } + final githubToken = Platform.environment['GITHUB_TOKEN']; + if (githubToken == null) { + throw ArgumentError('Missing GITHUB_TOKEN environment variable'); + } + final privateKey = HEX.decode(privateKeyString); + if (privateKey.length != 64) { + throw ArgumentError('Private key must be 64 bytes long'); + } + final manifestDir = argResults!['manifest-dir'] as String; + if (!Directory(manifestDir).existsSync()) { + throw ArgumentError('Manifest directory does not exist: $manifestDir'); + } + String? androidMinSdkVersionString = + argResults!['android-min-sdk-version'] as String?; + int? androidMinSdkVersion; + if (androidMinSdkVersionString != null) { + androidMinSdkVersion = int.tryParse(androidMinSdkVersionString); + if (androidMinSdkVersion == null) { + throw ArgumentError( + 'Invalid android-min-sdk-version: $androidMinSdkVersionString'); + } + } + final targetStrigns = argResults!['target'] as List; + final targets = targetStrigns.map((target) { + final res = Target.forRustTriple(target); + if (res == null) { + throw ArgumentError('Invalid target: $target'); + } + return res; + }).toList(growable: false); + final precompileBinaries = PrecompileBinaries( + privateKey: PrivateKey(privateKey), + githubToken: githubToken, + manifestDir: manifestDir, + repositorySlug: RepositorySlug.full(argResults!['repository'] as String), + targets: targets, + androidSdkLocation: argResults!['android-sdk-location'] as String?, + androidNdkVersion: argResults!['android-ndk-version'] as String?, + androidMinSdkVersion: androidMinSdkVersion, + tempDir: argResults!['temp-dir'] as String?, + ); + + await precompileBinaries.run(); + } +} + +class VerifyBinariesCommand extends Command { + VerifyBinariesCommand() { + argParser.addOption( + 'manifest-dir', + mandatory: true, + help: 'Directory containing Cargo.toml', + ); + } + + @override + final name = "verify-binaries"; + + @override + final description = 'Verifies published binaries\n' + 'Checks whether there is a binary published for each targets\n' + 'and checks the signature.'; + + @override + Future run() async { + final manifestDir = argResults!['manifest-dir'] as String; + final verifyBinaries = VerifyBinaries( + manifestDir: manifestDir, + ); + await verifyBinaries.run(); + } +} + +Future runMain(List args) async { + try { + // Init logging before options are loaded + initLogging(); + + if (Platform.environment['_CARGOKIT_NDK_LINK_TARGET'] != null) { + return AndroidEnvironment.clangLinkerWrapper(args); + } + + final runner = CommandRunner('build_tool', 'Cargokit built_tool') + ..addCommand(BuildPodCommand()) + ..addCommand(BuildGradleCommand()) + ..addCommand(BuildCMakeCommand()) + ..addCommand(GenKeyCommand()) + ..addCommand(PrecompileBinariesCommand()) + ..addCommand(VerifyBinariesCommand()); + + await runner.run(args); + } on ArgumentError catch (e) { + stderr.writeln(e.toString()); + exit(1); + } catch (e, s) { + log.severe(kDoubleSeparator); + log.severe('Cargokit BuildTool failed with error:'); + log.severe(kSeparator); + log.severe(e); + // This tells user to install Rust, there's no need to pollute the log with + // stack trace. + if (e is! RustupNotFoundException) { + log.severe(kSeparator); + log.severe(s); + log.severe(kSeparator); + log.severe('BuildTool arguments: $args'); + } + log.severe(kDoubleSeparator); + exit(1); + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/builder.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/builder.dart new file mode 100644 index 0000000000..84c46e4f54 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/builder.dart @@ -0,0 +1,198 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'android_environment.dart'; +import 'cargo.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'rustup.dart'; +import 'target.dart'; +import 'util.dart'; + +final _log = Logger('builder'); + +enum BuildConfiguration { + debug, + release, + profile, +} + +extension on BuildConfiguration { + bool get isDebug => this == BuildConfiguration.debug; + String get rustName => switch (this) { + BuildConfiguration.debug => 'debug', + BuildConfiguration.release => 'release', + BuildConfiguration.profile => 'release', + }; +} + +class BuildException implements Exception { + final String message; + + BuildException(this.message); + + @override + String toString() { + return 'BuildException: $message'; + } +} + +class BuildEnvironment { + final BuildConfiguration configuration; + final CargokitCrateOptions crateOptions; + final String targetTempDir; + final String manifestDir; + final CrateInfo crateInfo; + + final bool isAndroid; + final String? androidSdkPath; + final String? androidNdkVersion; + final int? androidMinSdkVersion; + final String? javaHome; + + BuildEnvironment({ + required this.configuration, + required this.crateOptions, + required this.targetTempDir, + required this.manifestDir, + required this.crateInfo, + required this.isAndroid, + this.androidSdkPath, + this.androidNdkVersion, + this.androidMinSdkVersion, + this.javaHome, + }); + + static BuildConfiguration parseBuildConfiguration(String value) { + // XCode configuration adds the flavor to configuration name. + final firstSegment = value.split('-').first; + final buildConfiguration = BuildConfiguration.values.firstWhereOrNull( + (e) => e.name == firstSegment, + ); + if (buildConfiguration == null) { + _log.warning('Unknown build configuraiton $value, will assume release'); + return BuildConfiguration.release; + } + return buildConfiguration; + } + + static BuildEnvironment fromEnvironment({ + required bool isAndroid, + }) { + final buildConfiguration = + parseBuildConfiguration(Environment.configuration); + final manifestDir = Environment.manifestDir; + final crateOptions = CargokitCrateOptions.load( + manifestDir: manifestDir, + ); + final crateInfo = CrateInfo.load(manifestDir); + return BuildEnvironment( + configuration: buildConfiguration, + crateOptions: crateOptions, + targetTempDir: Environment.targetTempDir, + manifestDir: manifestDir, + crateInfo: crateInfo, + isAndroid: isAndroid, + androidSdkPath: isAndroid ? Environment.sdkPath : null, + androidNdkVersion: isAndroid ? Environment.ndkVersion : null, + androidMinSdkVersion: + isAndroid ? int.parse(Environment.minSdkVersion) : null, + javaHome: isAndroid ? Environment.javaHome : null, + ); + } +} + +class RustBuilder { + final Target target; + final BuildEnvironment environment; + + RustBuilder({ + required this.target, + required this.environment, + }); + + void prepare( + Rustup rustup, + ) { + final toolchain = _toolchain; + if (rustup.installedTargets(toolchain) == null) { + rustup.installToolchain(toolchain); + } + if (toolchain == 'nightly') { + rustup.installRustSrcForNightly(); + } + if (!rustup.installedTargets(toolchain)!.contains(target.rust)) { + rustup.installTarget(target.rust, toolchain: toolchain); + } + } + + CargoBuildOptions? get _buildOptions => + environment.crateOptions.cargo[environment.configuration]; + + String get _toolchain => _buildOptions?.toolchain.name ?? 'stable'; + + /// Returns the path of directory containing build artifacts. + Future build() async { + final extraArgs = _buildOptions?.flags ?? []; + final manifestPath = path.join(environment.manifestDir, 'Cargo.toml'); + runCommand( + 'rustup', + [ + 'run', + _toolchain, + 'cargo', + 'build', + ...extraArgs, + '--manifest-path', + manifestPath, + '-p', + environment.crateInfo.packageName, + if (!environment.configuration.isDebug) '--release', + '--target', + target.rust, + '--target-dir', + environment.targetTempDir, + ], + environment: await _buildEnvironment(), + ); + return path.join( + environment.targetTempDir, + target.rust, + environment.configuration.rustName, + ); + } + + Future> _buildEnvironment() async { + if (target.android == null) { + return {}; + } else { + final sdkPath = environment.androidSdkPath; + final ndkVersion = environment.androidNdkVersion; + final minSdkVersion = environment.androidMinSdkVersion; + if (sdkPath == null) { + throw BuildException('androidSdkPath is not set'); + } + if (ndkVersion == null) { + throw BuildException('androidNdkVersion is not set'); + } + if (minSdkVersion == null) { + throw BuildException('androidMinSdkVersion is not set'); + } + final env = AndroidEnvironment( + sdkPath: sdkPath, + ndkVersion: ndkVersion, + minSdkVersion: minSdkVersion, + targetTempDir: environment.targetTempDir, + target: target, + ); + if (!env.ndkIsInstalled() && environment.javaHome != null) { + env.installNdk(javaHome: environment.javaHome!); + } + return env.buildEnvironment(); + } + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/cargo.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/cargo.dart new file mode 100644 index 0000000000..0d8958ff2e --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/cargo.dart @@ -0,0 +1,48 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:toml/toml.dart'; + +class ManifestException { + ManifestException(this.message, {required this.fileName}); + + final String? fileName; + final String message; + + @override + String toString() { + if (fileName != null) { + return 'Failed to parse package manifest at $fileName: $message'; + } else { + return 'Failed to parse package manifest: $message'; + } + } +} + +class CrateInfo { + CrateInfo({required this.packageName}); + + final String packageName; + + static CrateInfo parseManifest(String manifest, {final String? fileName}) { + final toml = TomlDocument.parse(manifest); + final package = toml.toMap()['package']; + if (package == null) { + throw ManifestException('Missing package section', fileName: fileName); + } + final name = package['name']; + if (name == null) { + throw ManifestException('Missing package name', fileName: fileName); + } + return CrateInfo(packageName: name); + } + + static CrateInfo load(String manifestDir) { + final manifestFile = File(path.join(manifestDir, 'Cargo.toml')); + final manifest = manifestFile.readAsStringSync(); + return parseManifest(manifest, fileName: manifestFile.path); + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/crate_hash.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/crate_hash.dart new file mode 100644 index 0000000000..0c4d88d16b --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/crate_hash.dart @@ -0,0 +1,124 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as path; + +class CrateHash { + /// Computes a hash uniquely identifying crate content. This takes into account + /// content all all .rs files inside the src directory, as well as Cargo.toml, + /// Cargo.lock, build.rs and cargokit.yaml. + /// + /// If [tempStorage] is provided, computed hash is stored in a file in that directory + /// and reused on subsequent calls if the crate content hasn't changed. + static String compute(String manifestDir, {String? tempStorage}) { + return CrateHash._( + manifestDir: manifestDir, + tempStorage: tempStorage, + )._compute(); + } + + CrateHash._({ + required this.manifestDir, + required this.tempStorage, + }); + + String _compute() { + final files = getFiles(); + final tempStorage = this.tempStorage; + if (tempStorage != null) { + final quickHash = _computeQuickHash(files); + final quickHashFolder = Directory(path.join(tempStorage, 'crate_hash')); + quickHashFolder.createSync(recursive: true); + final quickHashFile = File(path.join(quickHashFolder.path, quickHash)); + if (quickHashFile.existsSync()) { + return quickHashFile.readAsStringSync(); + } + final hash = _computeHash(files); + quickHashFile.writeAsStringSync(hash); + return hash; + } else { + return _computeHash(files); + } + } + + /// Computes a quick hash based on files stat (without reading contents). This + /// is used to cache the real hash, which is slower to compute since it involves + /// reading every single file. + String _computeQuickHash(List files) { + final output = AccumulatorSink(); + final input = sha256.startChunkedConversion(output); + + final data = ByteData(8); + for (final file in files) { + input.add(utf8.encode(file.path)); + final stat = file.statSync(); + data.setUint64(0, stat.size); + input.add(data.buffer.asUint8List()); + data.setUint64(0, stat.modified.millisecondsSinceEpoch); + input.add(data.buffer.asUint8List()); + } + + input.close(); + return base64Url.encode(output.events.single.bytes); + } + + String _computeHash(List files) { + final output = AccumulatorSink(); + final input = sha256.startChunkedConversion(output); + + void addTextFile(File file) { + // text Files are hashed by lines in case we're dealing with github checkout + // that auto-converts line endings. + final splitter = LineSplitter(); + if (file.existsSync()) { + final data = file.readAsStringSync(); + final lines = splitter.convert(data); + for (final line in lines) { + input.add(utf8.encode(line)); + } + } + } + + for (final file in files) { + addTextFile(file); + } + + input.close(); + final res = output.events.single; + + // Truncate to 128bits. + final hash = res.bytes.sublist(0, 16); + return hex.encode(hash); + } + + List getFiles() { + final src = Directory(path.join(manifestDir, 'src')); + final files = src + .listSync(recursive: true, followLinks: false) + .whereType() + .toList(); + files.sortBy((element) => element.path); + void addFile(String relative) { + final file = File(path.join(manifestDir, relative)); + if (file.existsSync()) { + files.add(file); + } + } + + addFile('Cargo.toml'); + addFile('Cargo.lock'); + addFile('build.rs'); + addFile('cargokit.yaml'); + return files; + } + + final String manifestDir; + final String? tempStorage; +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/environment.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/environment.dart new file mode 100644 index 0000000000..996483a180 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/environment.dart @@ -0,0 +1,68 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +extension on String { + String resolveSymlink() => File(this).resolveSymbolicLinksSync(); +} + +class Environment { + /// Current build configuration (debug or release). + static String get configuration => + _getEnv("CARGOKIT_CONFIGURATION").toLowerCase(); + + static bool get isDebug => configuration == 'debug'; + static bool get isRelease => configuration == 'release'; + + /// Temporary directory where Rust build artifacts are placed. + static String get targetTempDir => _getEnv("CARGOKIT_TARGET_TEMP_DIR"); + + /// Final output directory where the build artifacts are placed. + static String get outputDir => _getEnvPath('CARGOKIT_OUTPUT_DIR'); + + /// Path to the crate manifest (containing Cargo.toml). + static String get manifestDir => _getEnvPath('CARGOKIT_MANIFEST_DIR'); + + /// Directory inside root project. Not necessarily root folder. Symlinks are + /// not resolved on purpose. + static String get rootProjectDir => _getEnv('CARGOKIT_ROOT_PROJECT_DIR'); + + // Pod + + /// Platform name (macosx, iphoneos, iphonesimulator). + static String get darwinPlatformName => + _getEnv("CARGOKIT_DARWIN_PLATFORM_NAME"); + + /// List of architectures to build for (arm64, armv7, x86_64). + static List get darwinArchs => + _getEnv("CARGOKIT_DARWIN_ARCHS").split(' '); + + // Gradle + static String get minSdkVersion => _getEnv("CARGOKIT_MIN_SDK_VERSION"); + static String get ndkVersion => _getEnv("CARGOKIT_NDK_VERSION"); + static String get sdkPath => _getEnvPath("CARGOKIT_SDK_DIR"); + static String get javaHome => _getEnvPath("CARGOKIT_JAVA_HOME"); + static List get targetPlatforms => + _getEnv("CARGOKIT_TARGET_PLATFORMS").split(','); + + // CMAKE + static String get targetPlatform => _getEnv("CARGOKIT_TARGET_PLATFORM"); + + static String _getEnv(String key) { + final res = Platform.environment[key]; + if (res == null) { + throw Exception("Missing environment variable $key"); + } + return res; + } + + static String _getEnvPath(String key) { + final res = _getEnv(key); + if (Directory(res).existsSync()) { + return res.resolveSymlink(); + } else { + return res; + } + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/logging.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/logging.dart new file mode 100644 index 0000000000..5edd4fd184 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/logging.dart @@ -0,0 +1,52 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:logging/logging.dart'; + +const String kSeparator = "--"; +const String kDoubleSeparator = "=="; + +bool _lastMessageWasSeparator = false; + +void _log(LogRecord rec) { + final prefix = '${rec.level.name}: '; + final out = rec.level == Level.SEVERE ? stderr : stdout; + if (rec.message == kSeparator) { + if (!_lastMessageWasSeparator) { + out.write(prefix); + out.writeln('-' * 80); + _lastMessageWasSeparator = true; + } + return; + } else if (rec.message == kDoubleSeparator) { + out.write(prefix); + out.writeln('=' * 80); + _lastMessageWasSeparator = true; + return; + } + out.write(prefix); + out.writeln(rec.message); + _lastMessageWasSeparator = false; +} + +void initLogging() { + Logger.root.level = Level.INFO; + Logger.root.onRecord.listen((LogRecord rec) { + final lines = rec.message.split('\n'); + for (final line in lines) { + if (line.isNotEmpty || lines.length == 1 || line != lines.last) { + _log(LogRecord( + rec.level, + line, + rec.loggerName, + )); + } + } + }); +} + +void enableVerboseLogging() { + Logger.root.level = Level.ALL; +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/options.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/options.dart new file mode 100644 index 0000000000..22aef1d371 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/options.dart @@ -0,0 +1,309 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:hex/hex.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; +import 'package:source_span/source_span.dart'; +import 'package:yaml/yaml.dart'; + +import 'builder.dart'; +import 'environment.dart'; +import 'rustup.dart'; + +final _log = Logger('options'); + +/// A class for exceptions that have source span information attached. +class SourceSpanException implements Exception { + // This is a getter so that subclasses can override it. + /// A message describing the exception. + String get message => _message; + final String _message; + + // This is a getter so that subclasses can override it. + /// The span associated with this exception. + /// + /// This may be `null` if the source location can't be determined. + SourceSpan? get span => _span; + final SourceSpan? _span; + + SourceSpanException(this._message, this._span); + + /// Returns a string representation of `this`. + /// + /// [color] may either be a [String], a [bool], or `null`. If it's a string, + /// it indicates an ANSI terminal color escape that should be used to + /// highlight the span's text. If it's `true`, it indicates that the text + /// should be highlighted using the default color. If it's `false` or `null`, + /// it indicates that the text shouldn't be highlighted. + @override + String toString({Object? color}) { + if (span == null) return message; + return 'Error on ${span!.message(message, color: color)}'; + } +} + +enum Toolchain { + stable, + beta, + nightly, +} + +class CargoBuildOptions { + final Toolchain toolchain; + final List flags; + + CargoBuildOptions({ + required this.toolchain, + required this.flags, + }); + + static Toolchain _toolchainFromNode(YamlNode node) { + if (node case YamlScalar(value: String name)) { + final toolchain = + Toolchain.values.firstWhereOrNull((element) => element.name == name); + if (toolchain != null) { + return toolchain; + } + } + throw SourceSpanException( + 'Unknown toolchain. Must be one of ${Toolchain.values.map((e) => e.name)}.', + node.span); + } + + static CargoBuildOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargo options must be a map', node.span); + } + Toolchain toolchain = Toolchain.stable; + List flags = []; + for (final MapEntry(:key, :value) in node.nodes.entries) { + if (key case YamlScalar(value: 'toolchain')) { + toolchain = _toolchainFromNode(value); + } else if (key case YamlScalar(value: 'extra_flags')) { + if (value case YamlList(nodes: List list)) { + if (list.every((element) { + if (element case YamlScalar(value: String _)) { + return true; + } + return false; + })) { + flags = list.map((e) => e.value as String).toList(); + continue; + } + } + throw SourceSpanException( + 'Extra flags must be a list of strings', value.span); + } else { + throw SourceSpanException( + 'Unknown cargo option type. Must be "toolchain" or "extra_flags".', + key.span); + } + } + return CargoBuildOptions(toolchain: toolchain, flags: flags); + } +} + +extension on YamlMap { + /// Map that extracts keys so that we can do map case check on them. + Map get valueMap => + nodes.map((key, value) => MapEntry(key.value, value)); +} + +class PrecompiledBinaries { + final String uriPrefix; + final PublicKey publicKey; + + PrecompiledBinaries({ + required this.uriPrefix, + required this.publicKey, + }); + + static PublicKey _publicKeyFromHex(String key, SourceSpan? span) { + final bytes = HEX.decode(key); + if (bytes.length != 32) { + throw SourceSpanException( + 'Invalid public key. Must be 32 bytes long.', span); + } + return PublicKey(bytes); + } + + static PrecompiledBinaries parse(YamlNode node) { + if (node case YamlMap(valueMap: Map map)) { + if (map + case { + 'url_prefix': YamlNode urlPrefixNode, + 'public_key': YamlNode publicKeyNode, + }) { + final urlPrefix = switch (urlPrefixNode) { + YamlScalar(value: String urlPrefix) => urlPrefix, + _ => throw SourceSpanException( + 'Invalid URL prefix value.', urlPrefixNode.span), + }; + final publicKey = switch (publicKeyNode) { + YamlScalar(value: String publicKey) => + _publicKeyFromHex(publicKey, publicKeyNode.span), + _ => throw SourceSpanException( + 'Invalid public key value.', publicKeyNode.span), + }; + return PrecompiledBinaries( + uriPrefix: urlPrefix, + publicKey: publicKey, + ); + } + } + throw SourceSpanException( + 'Invalid precompiled binaries value. ' + 'Expected Map with "url_prefix" and "public_key".', + node.span); + } +} + +/// Cargokit options specified for Rust crate. +class CargokitCrateOptions { + CargokitCrateOptions({ + this.cargo = const {}, + this.precompiledBinaries, + }); + + final Map cargo; + final PrecompiledBinaries? precompiledBinaries; + + static CargokitCrateOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargokit options must be a map', node.span); + } + final options = {}; + PrecompiledBinaries? precompiledBinaries; + + for (final entry in node.nodes.entries) { + if (entry + case MapEntry( + key: YamlScalar(value: 'cargo'), + value: YamlNode node, + )) { + if (node is! YamlMap) { + throw SourceSpanException('Cargo options must be a map', node.span); + } + for (final MapEntry(:YamlNode key, :value) in node.nodes.entries) { + if (key case YamlScalar(value: String name)) { + final configuration = BuildConfiguration.values + .firstWhereOrNull((element) => element.name == name); + if (configuration != null) { + options[configuration] = CargoBuildOptions.parse(value); + continue; + } + } + throw SourceSpanException( + 'Unknown build configuration. Must be one of ${BuildConfiguration.values.map((e) => e.name)}.', + key.span); + } + } else if (entry.key case YamlScalar(value: 'precompiled_binaries')) { + precompiledBinaries = PrecompiledBinaries.parse(entry.value); + } else { + throw SourceSpanException( + 'Unknown cargokit option type. Must be "cargo" or "precompiled_binaries".', + entry.key.span); + } + } + return CargokitCrateOptions( + cargo: options, + precompiledBinaries: precompiledBinaries, + ); + } + + static CargokitCrateOptions load({ + required String manifestDir, + }) { + final uri = Uri.file(path.join(manifestDir, "cargokit.yaml")); + final file = File.fromUri(uri); + if (file.existsSync()) { + final contents = loadYamlNode(file.readAsStringSync(), sourceUrl: uri); + return parse(contents); + } else { + return CargokitCrateOptions(); + } + } +} + +class CargokitUserOptions { + // When Rustup is installed always build locally unless user opts into + // using precompiled binaries. + static bool defaultUsePrecompiledBinaries() { + return Rustup.executablePath() == null; + } + + CargokitUserOptions({ + required this.usePrecompiledBinaries, + required this.verboseLogging, + }); + + CargokitUserOptions._() + : usePrecompiledBinaries = defaultUsePrecompiledBinaries(), + verboseLogging = false; + + static CargokitUserOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargokit options must be a map', node.span); + } + bool usePrecompiledBinaries = defaultUsePrecompiledBinaries(); + bool verboseLogging = false; + + for (final entry in node.nodes.entries) { + if (entry.key case YamlScalar(value: 'use_precompiled_binaries')) { + if (entry.value case YamlScalar(value: bool value)) { + usePrecompiledBinaries = value; + continue; + } + throw SourceSpanException( + 'Invalid value for "use_precompiled_binaries". Must be a boolean.', + entry.value.span); + } else if (entry.key case YamlScalar(value: 'verbose_logging')) { + if (entry.value case YamlScalar(value: bool value)) { + verboseLogging = value; + continue; + } + throw SourceSpanException( + 'Invalid value for "verbose_logging". Must be a boolean.', + entry.value.span); + } else { + throw SourceSpanException( + 'Unknown cargokit option type. Must be "use_precompiled_binaries" or "verbose_logging".', + entry.key.span); + } + } + return CargokitUserOptions( + usePrecompiledBinaries: usePrecompiledBinaries, + verboseLogging: verboseLogging, + ); + } + + static CargokitUserOptions load() { + String fileName = "cargokit_options.yaml"; + var userProjectDir = Directory(Environment.rootProjectDir); + + while (userProjectDir.parent.path != userProjectDir.path) { + final configFile = File(path.join(userProjectDir.path, fileName)); + if (configFile.existsSync()) { + final contents = loadYamlNode( + configFile.readAsStringSync(), + sourceUrl: configFile.uri, + ); + final res = parse(contents); + if (res.verboseLogging) { + _log.info('Found user options file at ${configFile.path}'); + } + return res; + } + userProjectDir = userProjectDir.parent; + } + return CargokitUserOptions._(); + } + + final bool usePrecompiledBinaries; + final bool verboseLogging; +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/precompile_binaries.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/precompile_binaries.dart new file mode 100644 index 0000000000..c27f4195dd --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/precompile_binaries.dart @@ -0,0 +1,202 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:github/github.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'cargo.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'rustup.dart'; +import 'target.dart'; + +final _log = Logger('precompile_binaries'); + +class PrecompileBinaries { + PrecompileBinaries({ + required this.privateKey, + required this.githubToken, + required this.repositorySlug, + required this.manifestDir, + required this.targets, + this.androidSdkLocation, + this.androidNdkVersion, + this.androidMinSdkVersion, + this.tempDir, + }); + + final PrivateKey privateKey; + final String githubToken; + final RepositorySlug repositorySlug; + final String manifestDir; + final List targets; + final String? androidSdkLocation; + final String? androidNdkVersion; + final int? androidMinSdkVersion; + final String? tempDir; + + static String fileName(Target target, String name) { + return '${target.rust}_$name'; + } + + static String signatureFileName(Target target, String name) { + return '${target.rust}_$name.sig'; + } + + Future run() async { + final crateInfo = CrateInfo.load(manifestDir); + + final targets = List.of(this.targets); + if (targets.isEmpty) { + targets.addAll([ + ...Target.buildableTargets(), + if (androidSdkLocation != null) ...Target.androidTargets(), + ]); + } + + _log.info('Precompiling binaries for $targets'); + + final hash = CrateHash.compute(manifestDir); + _log.info('Computed crate hash: $hash'); + + final String tagName = 'precompiled_$hash'; + + final github = GitHub(auth: Authentication.withToken(githubToken)); + final repo = github.repositories; + final release = await _getOrCreateRelease( + repo: repo, + tagName: tagName, + packageName: crateInfo.packageName, + hash: hash, + ); + + final tempDir = this.tempDir != null + ? Directory(this.tempDir!) + : Directory.systemTemp.createTempSync('precompiled_'); + + tempDir.createSync(recursive: true); + + final crateOptions = CargokitCrateOptions.load( + manifestDir: manifestDir, + ); + + final buildEnvironment = BuildEnvironment( + configuration: BuildConfiguration.release, + crateOptions: crateOptions, + targetTempDir: tempDir.path, + manifestDir: manifestDir, + crateInfo: crateInfo, + isAndroid: androidSdkLocation != null, + androidSdkPath: androidSdkLocation, + androidNdkVersion: androidNdkVersion, + androidMinSdkVersion: androidMinSdkVersion, + ); + + final rustup = Rustup(); + + for (final target in targets) { + final artifactNames = getArtifactNames( + target: target, + libraryName: crateInfo.packageName, + remote: true, + ); + + if (artifactNames.every((name) { + final fileName = PrecompileBinaries.fileName(target, name); + return (release.assets ?? []).any((e) => e.name == fileName); + })) { + _log.info("All artifacts for $target already exist - skipping"); + continue; + } + + _log.info('Building for $target'); + + final builder = + RustBuilder(target: target, environment: buildEnvironment); + builder.prepare(rustup); + final res = await builder.build(); + + final assets = []; + for (final name in artifactNames) { + final file = File(path.join(res, name)); + if (!file.existsSync()) { + throw Exception('Missing artifact: ${file.path}'); + } + + final data = file.readAsBytesSync(); + final create = CreateReleaseAsset( + name: PrecompileBinaries.fileName(target, name), + contentType: "application/octet-stream", + assetData: data, + ); + final signature = sign(privateKey, data); + final signatureCreate = CreateReleaseAsset( + name: signatureFileName(target, name), + contentType: "application/octet-stream", + assetData: signature, + ); + bool verified = verify(public(privateKey), data, signature); + if (!verified) { + throw Exception('Signature verification failed'); + } + assets.add(create); + assets.add(signatureCreate); + } + _log.info('Uploading assets: ${assets.map((e) => e.name)}'); + for (final asset in assets) { + // This seems to be failing on CI so do it one by one + int retryCount = 0; + while (true) { + try { + await repo.uploadReleaseAssets(release, [asset]); + break; + } on Exception catch (e) { + if (retryCount == 10) { + rethrow; + } + ++retryCount; + _log.shout( + 'Upload failed (attempt $retryCount, will retry): ${e.toString()}'); + await Future.delayed(Duration(seconds: 2)); + } + } + } + } + + _log.info('Cleaning up'); + tempDir.deleteSync(recursive: true); + } + + Future _getOrCreateRelease({ + required RepositoriesService repo, + required String tagName, + required String packageName, + required String hash, + }) async { + Release release; + try { + _log.info('Fetching release $tagName'); + release = await repo.getReleaseByTagName(repositorySlug, tagName); + } on ReleaseNotFound { + _log.info('Release not found - creating release $tagName'); + release = await repo.createRelease( + repositorySlug, + CreateRelease.from( + tagName: tagName, + name: 'Precompiled binaries ${hash.substring(0, 8)}', + targetCommitish: null, + isDraft: false, + isPrerelease: false, + body: 'Precompiled binaries for crate $packageName, ' + 'crate hash $hash.', + )); + } + return release; + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/rustup.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/rustup.dart new file mode 100644 index 0000000000..0ac8d08616 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/rustup.dart @@ -0,0 +1,136 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as path; + +import 'util.dart'; + +class _Toolchain { + _Toolchain( + this.name, + this.targets, + ); + + final String name; + final List targets; +} + +class Rustup { + List? installedTargets(String toolchain) { + final targets = _installedTargets(toolchain); + return targets != null ? List.unmodifiable(targets) : null; + } + + void installToolchain(String toolchain) { + log.info("Installing Rust toolchain: $toolchain"); + runCommand("rustup", ['toolchain', 'install', toolchain]); + _installedToolchains + .add(_Toolchain(toolchain, _getInstalledTargets(toolchain))); + } + + void installTarget( + String target, { + required String toolchain, + }) { + log.info("Installing Rust target: $target"); + runCommand("rustup", [ + 'target', + 'add', + '--toolchain', + toolchain, + target, + ]); + _installedTargets(toolchain)?.add(target); + } + + final List<_Toolchain> _installedToolchains; + + Rustup() : _installedToolchains = _getInstalledToolchains(); + + List? _installedTargets(String toolchain) => _installedToolchains + .firstWhereOrNull( + (e) => e.name == toolchain || e.name.startsWith('$toolchain-')) + ?.targets; + + static List<_Toolchain> _getInstalledToolchains() { + String extractToolchainName(String line) { + // ignore (default) after toolchain name + final parts = line.split(' '); + return parts[0]; + } + + final res = runCommand("rustup", ['toolchain', 'list']); + + // To list all non-custom toolchains, we need to filter out lines that + // don't start with "stable", "beta", or "nightly". + Pattern nonCustom = RegExp(r"^(stable|beta|nightly)"); + final lines = res.stdout + .toString() + .split('\n') + .where((e) => e.isNotEmpty && e.startsWith(nonCustom)) + .map(extractToolchainName) + .toList(growable: true); + + return lines + .map( + (name) => _Toolchain( + name, + _getInstalledTargets(name), + ), + ) + .toList(growable: true); + } + + static List _getInstalledTargets(String toolchain) { + final res = runCommand("rustup", [ + 'target', + 'list', + '--toolchain', + toolchain, + '--installed', + ]); + final lines = res.stdout + .toString() + .split('\n') + .where((e) => e.isNotEmpty) + .toList(growable: true); + return lines; + } + + bool _didInstallRustSrcForNightly = false; + + void installRustSrcForNightly() { + if (_didInstallRustSrcForNightly) { + return; + } + // Useful for -Z build-std + runCommand( + "rustup", + ['component', 'add', 'rust-src', '--toolchain', 'nightly'], + ); + _didInstallRustSrcForNightly = true; + } + + static String? executablePath() { + final envPath = Platform.environment['PATH']; + final envPathSeparator = Platform.isWindows ? ';' : ':'; + final home = Platform.isWindows + ? Platform.environment['USERPROFILE'] + : Platform.environment['HOME']; + final paths = [ + if (home != null) path.join(home, '.cargo', 'bin'), + if (envPath != null) ...envPath.split(envPathSeparator), + ]; + for (final p in paths) { + final rustup = Platform.isWindows ? 'rustup.exe' : 'rustup'; + final rustupPath = path.join(p, rustup); + if (File(rustupPath).existsSync()) { + return rustupPath; + } + } + return null; + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/target.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/target.dart new file mode 100644 index 0000000000..6fbc58b64f --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/target.dart @@ -0,0 +1,140 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; + +import 'util.dart'; + +class Target { + Target({ + required this.rust, + this.flutter, + this.android, + this.androidMinSdkVersion, + this.darwinPlatform, + this.darwinArch, + }); + + static final all = [ + Target( + rust: 'armv7-linux-androideabi', + flutter: 'android-arm', + android: 'armeabi-v7a', + androidMinSdkVersion: 16, + ), + Target( + rust: 'aarch64-linux-android', + flutter: 'android-arm64', + android: 'arm64-v8a', + androidMinSdkVersion: 21, + ), + Target( + rust: 'i686-linux-android', + flutter: 'android-x86', + android: 'x86', + androidMinSdkVersion: 16, + ), + Target( + rust: 'x86_64-linux-android', + flutter: 'android-x64', + android: 'x86_64', + androidMinSdkVersion: 21, + ), + Target( + rust: 'x86_64-pc-windows-msvc', + flutter: 'windows-x64', + ), + Target( + rust: 'x86_64-unknown-linux-gnu', + flutter: 'linux-x64', + ), + Target( + rust: 'aarch64-unknown-linux-gnu', + flutter: 'linux-arm64', + ), + Target( + rust: 'x86_64-apple-darwin', + darwinPlatform: 'macosx', + darwinArch: 'x86_64', + ), + Target( + rust: 'aarch64-apple-darwin', + darwinPlatform: 'macosx', + darwinArch: 'arm64', + ), + Target( + rust: 'aarch64-apple-ios', + darwinPlatform: 'iphoneos', + darwinArch: 'arm64', + ), + Target( + rust: 'aarch64-apple-ios-sim', + darwinPlatform: 'iphonesimulator', + darwinArch: 'arm64', + ), + Target( + rust: 'x86_64-apple-ios', + darwinPlatform: 'iphonesimulator', + darwinArch: 'x86_64', + ), + ]; + + static Target? forFlutterName(String flutterName) { + return all.firstWhereOrNull((element) => element.flutter == flutterName); + } + + static Target? forDarwin({ + required String platformName, + required String darwinAarch, + }) { + return all.firstWhereOrNull((element) => // + element.darwinPlatform == platformName && + element.darwinArch == darwinAarch); + } + + static Target? forRustTriple(String triple) { + return all.firstWhereOrNull((element) => element.rust == triple); + } + + static List androidTargets() { + return all + .where((element) => element.android != null) + .toList(growable: false); + } + + /// Returns buildable targets on current host platform ignoring Android targets. + static List buildableTargets() { + if (Platform.isLinux) { + // Right now we don't support cross-compiling on Linux. So we just return + // the host target. + final arch = runCommand('arch', []).stdout as String; + if (arch.trim() == 'aarch64') { + return [Target.forRustTriple('aarch64-unknown-linux-gnu')!]; + } else { + return [Target.forRustTriple('x86_64-unknown-linux-gnu')!]; + } + } + return all.where((target) { + if (Platform.isWindows) { + return target.rust.contains('-windows-'); + } else if (Platform.isMacOS) { + return target.darwinPlatform != null; + } + return false; + }).toList(growable: false); + } + + @override + String toString() { + return rust; + } + + final String? flutter; + final String rust; + final String? android; + final int? androidMinSdkVersion; + final String? darwinPlatform; + final String? darwinArch; +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/util.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/util.dart new file mode 100644 index 0000000000..8bb6a8724f --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/util.dart @@ -0,0 +1,172 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:convert'; +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'logging.dart'; +import 'rustup.dart'; + +final log = Logger("process"); + +class CommandFailedException implements Exception { + final String executable; + final List arguments; + final ProcessResult result; + + CommandFailedException({ + required this.executable, + required this.arguments, + required this.result, + }); + + @override + String toString() { + final stdout = result.stdout.toString().trim(); + final stderr = result.stderr.toString().trim(); + return [ + "External Command: $executable ${arguments.map((e) => '"$e"').join(' ')}", + "Returned Exit Code: ${result.exitCode}", + kSeparator, + "STDOUT:", + if (stdout.isNotEmpty) stdout, + kSeparator, + "STDERR:", + if (stderr.isNotEmpty) stderr, + ].join('\n'); + } +} + +class TestRunCommandArgs { + final String executable; + final List arguments; + final String? workingDirectory; + final Map? environment; + final bool includeParentEnvironment; + final bool runInShell; + final Encoding? stdoutEncoding; + final Encoding? stderrEncoding; + + TestRunCommandArgs({ + required this.executable, + required this.arguments, + this.workingDirectory, + this.environment, + this.includeParentEnvironment = true, + this.runInShell = false, + this.stdoutEncoding, + this.stderrEncoding, + }); +} + +class TestRunCommandResult { + TestRunCommandResult({ + this.pid = 1, + this.exitCode = 0, + this.stdout = '', + this.stderr = '', + }); + + final int pid; + final int exitCode; + final String stdout; + final String stderr; +} + +TestRunCommandResult Function(TestRunCommandArgs args)? testRunCommandOverride; + +ProcessResult runCommand( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, +}) { + if (testRunCommandOverride != null) { + final result = testRunCommandOverride!(TestRunCommandArgs( + executable: executable, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding, + )); + return ProcessResult( + result.pid, + result.exitCode, + result.stdout, + result.stderr, + ); + } + log.finer('Running command $executable ${arguments.join(' ')}'); + final res = Process.runSync( + _resolveExecutable(executable), + arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stderrEncoding: stderrEncoding, + stdoutEncoding: stdoutEncoding, + ); + if (res.exitCode != 0) { + throw CommandFailedException( + executable: executable, + arguments: arguments, + result: res, + ); + } else { + return res; + } +} + +class RustupNotFoundException implements Exception { + @override + String toString() { + return [ + ' ', + 'rustup not found in PATH.', + ' ', + 'Maybe you need to install Rust? It only takes a minute:', + ' ', + if (Platform.isWindows) 'https://www.rust-lang.org/tools/install', + if (hasHomebrewRustInPath()) ...[ + '\$ brew unlink rust # Unlink homebrew Rust from PATH', + ], + if (!Platform.isWindows) + "\$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh", + ' ', + ].join('\n'); + } + + static bool hasHomebrewRustInPath() { + if (!Platform.isMacOS) { + return false; + } + final envPath = Platform.environment['PATH'] ?? ''; + final paths = envPath.split(':'); + return paths.any((p) { + return p.contains('homebrew') && File(path.join(p, 'rustc')).existsSync(); + }); + } +} + +String _resolveExecutable(String executable) { + if (executable == 'rustup') { + final resolved = Rustup.executablePath(); + if (resolved != null) { + return resolved; + } + throw RustupNotFoundException(); + } else { + return executable; + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/verify_binaries.dart b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/verify_binaries.dart new file mode 100644 index 0000000000..2366b57bfd --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/lib/src/verify_binaries.dart @@ -0,0 +1,84 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:http/http.dart'; + +import 'artifacts_provider.dart'; +import 'cargo.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'target.dart'; + +class VerifyBinaries { + VerifyBinaries({ + required this.manifestDir, + }); + + final String manifestDir; + + Future run() async { + final crateInfo = CrateInfo.load(manifestDir); + + final config = CargokitCrateOptions.load(manifestDir: manifestDir); + final precompiledBinaries = config.precompiledBinaries; + if (precompiledBinaries == null) { + stdout.writeln('Crate does not support precompiled binaries.'); + } else { + final crateHash = CrateHash.compute(manifestDir); + stdout.writeln('Crate hash: $crateHash'); + + for (final target in Target.all) { + final message = 'Checking ${target.rust}...'; + stdout.write(message.padRight(40)); + stdout.flush(); + + final artifacts = getArtifactNames( + target: target, + libraryName: crateInfo.packageName, + remote: true, + ); + + final prefix = precompiledBinaries.uriPrefix; + + bool ok = true; + + for (final artifact in artifacts) { + final fileName = PrecompileBinaries.fileName(target, artifact); + final signatureFileName = + PrecompileBinaries.signatureFileName(target, artifact); + + final url = Uri.parse('$prefix$crateHash/$fileName'); + final signatureUrl = + Uri.parse('$prefix$crateHash/$signatureFileName'); + + final signature = await get(signatureUrl); + if (signature.statusCode != 200) { + stdout.writeln('MISSING'); + ok = false; + break; + } + final asset = await get(url); + if (asset.statusCode != 200) { + stdout.writeln('MISSING'); + ok = false; + break; + } + + if (!verify(precompiledBinaries.publicKey, asset.bodyBytes, + signature.bodyBytes)) { + stdout.writeln('INVALID SIGNATURE'); + ok = false; + } + } + + if (ok) { + stdout.writeln('OK'); + } + } + } + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/pubspec.lock b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/pubspec.lock new file mode 100644 index 0000000000..343bdd3694 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/pubspec.lock @@ -0,0 +1,453 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + url: "https://pub.dev" + source: hosted + version: "64.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + url: "https://pub.dev" + source: hosted + version: "6.2.0" + args: + dependency: "direct main" + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + convert: + dependency: "direct main" + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" + url: "https://pub.dev" + source: hosted + version: "1.6.3" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + ed25519_edwards: + dependency: "direct main" + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + file: + dependency: transitive + description: + name: file + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + github: + dependency: "direct main" + description: + name: github + sha256: "9966bc13bf612342e916b0a343e95e5f046c88f602a14476440e9b75d2295411" + url: "https://pub.dev" + source: hosted + version: "9.17.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + hex: + dependency: "direct main" + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + http: + dependency: "direct main" + description: + name: http + sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" + source: hosted + version: "4.8.1" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + logging: + dependency: "direct main" + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + url: "https://pub.dev" + source: hosted + version: "0.12.16" + meta: + dependency: transitive + description: + name: meta + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + mime: + dependency: transitive + description: + name: mime + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + url: "https://pub.dev" + source: hosted + version: "1.0.4" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: "direct main" + description: + name: path + sha256: "2ad4cddff7f5cc0e2d13069f2a3f7a73ca18f66abd6f5ecf215219cdb3638edb" + url: "https://pub.dev" + source: hosted + version: "1.8.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + url: "https://pub.dev" + source: hosted + version: "5.4.0" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: "direct main" + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: "9b0dd8e36af4a5b1569029949d50a52cb2a2a2fdaa20cebb96e6603b9ae241f9" + url: "https://pub.dev" + source: hosted + version: "1.24.6" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + test_core: + dependency: transitive + description: + name: test_core + sha256: "4bef837e56375537055fdbbbf6dd458b1859881f4c7e6da936158f77d61ab265" + url: "https://pub.dev" + source: hosted + version: "0.5.6" + toml: + dependency: "direct main" + description: + name: toml + sha256: "157c5dca5160fced243f3ce984117f729c788bb5e475504f3dbcda881accee44" + url: "https://pub.dev" + source: hosted + version: "0.14.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + version: + dependency: "direct main" + description: + name: version + sha256: "2307e23a45b43f96469eeab946208ed63293e8afca9c28cd8b5241ff31c55f55" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0fae432c85c4ea880b33b497d32824b97795b04cdaa74d270219572a1f50268d" + url: "https://pub.dev" + source: hosted + version: "11.9.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + yaml: + dependency: "direct main" + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.0.0 <4.0.0" diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/pubspec.yaml b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/pubspec.yaml new file mode 100644 index 0000000000..18c61e3386 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/build_tool/pubspec.yaml @@ -0,0 +1,33 @@ +# This is copied from Cargokit (which is the official way to use it currently) +# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +name: build_tool +description: Cargokit build_tool. Facilitates the build of Rust crate during Flutter application build. +publish_to: none +version: 1.0.0 + +environment: + sdk: ">=3.0.0 <4.0.0" + +# Add regular dependencies here. +dependencies: + # these are pinned on purpose because the bundle_tool_runner doesn't have + # pubspec.lock. See run_build_tool.sh + logging: 1.2.0 + path: 1.8.0 + version: 3.0.0 + collection: 1.18.0 + ed25519_edwards: 0.3.1 + hex: 0.2.0 + yaml: 3.1.2 + source_span: 1.10.0 + github: 9.17.0 + args: 2.4.2 + crypto: 3.0.3 + convert: 3.1.1 + http: 1.1.0 + toml: 0.14.0 + +dev_dependencies: + lints: ^2.1.0 + test: ^1.24.0 diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/cmake/cargokit.cmake b/turms-chat-demo-flutter/rust_builder/cargokit/cmake/cargokit.cmake new file mode 100644 index 0000000000..ddd05df9b4 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/cmake/cargokit.cmake @@ -0,0 +1,99 @@ +SET(cargokit_cmake_root "${CMAKE_CURRENT_LIST_DIR}/..") + +# Workaround for https://github.com/dart-lang/pub/issues/4010 +get_filename_component(cargokit_cmake_root "${cargokit_cmake_root}" REALPATH) + +if(WIN32) + # REALPATH does not properly resolve symlinks on windows :-/ + execute_process(COMMAND powershell -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_LIST_DIR}/resolve_symlinks.ps1" "${cargokit_cmake_root}" OUTPUT_VARIABLE cargokit_cmake_root OUTPUT_STRIP_TRAILING_WHITESPACE) +endif() + +# Arguments +# - target: CMAKE target to which rust library is linked +# - manifest_dir: relative path from current folder to directory containing cargo manifest +# - lib_name: cargo package name +# - any_symbol_name: name of any exported symbol from the library. +# used on windows to force linking with library. +function(apply_cargokit target manifest_dir lib_name any_symbol_name) + + set(CARGOKIT_LIB_NAME "${lib_name}") + set(CARGOKIT_LIB_FULL_NAME "${CMAKE_SHARED_MODULE_PREFIX}${CARGOKIT_LIB_NAME}${CMAKE_SHARED_MODULE_SUFFIX}") + if (CMAKE_CONFIGURATION_TYPES) + set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/$") + set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/$/${CARGOKIT_LIB_FULL_NAME}") + else() + set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}") + set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/${CARGOKIT_LIB_FULL_NAME}") + endif() + set(CARGOKIT_TEMP_DIR "${CMAKE_CURRENT_BINARY_DIR}/cargokit_build") + + if (FLUTTER_TARGET_PLATFORM) + set(CARGOKIT_TARGET_PLATFORM "${FLUTTER_TARGET_PLATFORM}") + else() + set(CARGOKIT_TARGET_PLATFORM "windows-x64") + endif() + + set(CARGOKIT_ENV + "CARGOKIT_CMAKE=${CMAKE_COMMAND}" + "CARGOKIT_CONFIGURATION=$" + "CARGOKIT_MANIFEST_DIR=${CMAKE_CURRENT_SOURCE_DIR}/${manifest_dir}" + "CARGOKIT_TARGET_TEMP_DIR=${CARGOKIT_TEMP_DIR}" + "CARGOKIT_OUTPUT_DIR=${CARGOKIT_OUTPUT_DIR}" + "CARGOKIT_TARGET_PLATFORM=${CARGOKIT_TARGET_PLATFORM}" + "CARGOKIT_TOOL_TEMP_DIR=${CARGOKIT_TEMP_DIR}/tool" + "CARGOKIT_ROOT_PROJECT_DIR=${CMAKE_SOURCE_DIR}" + ) + + if (WIN32) + set(SCRIPT_EXTENSION ".cmd") + set(IMPORT_LIB_EXTENSION ".lib") + else() + set(SCRIPT_EXTENSION ".sh") + set(IMPORT_LIB_EXTENSION "") + execute_process(COMMAND chmod +x "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}") + endif() + + # Using generators in custom command is only supported in CMake 3.20+ + if (CMAKE_CONFIGURATION_TYPES AND ${CMAKE_VERSION} VERSION_LESS "3.20.0") + foreach(CONFIG IN LISTS CMAKE_CONFIGURATION_TYPES) + add_custom_command( + OUTPUT + "${CMAKE_CURRENT_BINARY_DIR}/${CONFIG}/${CARGOKIT_LIB_FULL_NAME}" + "${CMAKE_CURRENT_BINARY_DIR}/_phony_" + COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV} + "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake + VERBATIM + ) + endforeach() + else() + add_custom_command( + OUTPUT + ${OUTPUT_LIB} + "${CMAKE_CURRENT_BINARY_DIR}/_phony_" + COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV} + "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake + VERBATIM + ) + endif() + + + set_source_files_properties("${CMAKE_CURRENT_BINARY_DIR}/_phony_" PROPERTIES SYMBOLIC TRUE) + + if (TARGET ${target}) + # If we have actual cmake target provided create target and make existing + # target depend on it + add_custom_target("${target}_cargokit" DEPENDS ${OUTPUT_LIB}) + add_dependencies("${target}" "${target}_cargokit") + target_link_libraries("${target}" PRIVATE "${OUTPUT_LIB}${IMPORT_LIB_EXTENSION}") + if(WIN32) + target_link_options(${target} PRIVATE "/INCLUDE:${any_symbol_name}") + endif() + else() + # Otherwise (FFI) just use ALL to force building always + add_custom_target("${target}_cargokit" ALL DEPENDS ${OUTPUT_LIB}) + endif() + + # Allow adding the output library to plugin bundled libraries + set("${target}_cargokit_lib" ${OUTPUT_LIB} PARENT_SCOPE) + +endfunction() diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/cmake/resolve_symlinks.ps1 b/turms-chat-demo-flutter/rust_builder/cargokit/cmake/resolve_symlinks.ps1 new file mode 100644 index 0000000000..3d10d283c2 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/cmake/resolve_symlinks.ps1 @@ -0,0 +1,27 @@ +function Resolve-Symlinks { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [string] $Path + ) + + [string] $separator = '/' + [string[]] $parts = $Path.Split($separator) + + [string] $realPath = '' + foreach ($part in $parts) { + if ($realPath -and !$realPath.EndsWith($separator)) { + $realPath += $separator + } + $realPath += $part + $item = Get-Item $realPath + if ($item.Target) { + $realPath = $item.Target.Replace('\', '/') + } + } + $realPath +} + +$path=Resolve-Symlinks -Path $args[0] +Write-Host $path diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/gradle/plugin.gradle b/turms-chat-demo-flutter/rust_builder/cargokit/gradle/plugin.gradle new file mode 100644 index 0000000000..1aead89136 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/gradle/plugin.gradle @@ -0,0 +1,179 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import java.nio.file.Paths +import org.apache.tools.ant.taskdefs.condition.Os + +CargoKitPlugin.file = buildscript.sourceFile + +apply plugin: CargoKitPlugin + +class CargoKitExtension { + String manifestDir; // Relative path to folder containing Cargo.toml + String libname; // Library name within Cargo.toml. Must be a cdylib +} + +abstract class CargoKitBuildTask extends DefaultTask { + + @Input + String buildMode + + @Input + String buildDir + + @Input + String outputDir + + @Input + String ndkVersion + + @Input + String sdkDirectory + + @Input + int compileSdkVersion; + + @Input + int minSdkVersion; + + @Input + String pluginFile + + @Input + List targetPlatforms + + @TaskAction + def build() { + if (project.cargokit.manifestDir == null) { + throw new GradleException("Property 'manifestDir' must be set on cargokit extension"); + } + + if (project.cargokit.libname == null) { + throw new GradleException("Property 'libname' must be set on cargokit extension"); + } + + def executableName = Os.isFamily(Os.FAMILY_WINDOWS) ? "run_build_tool.cmd" : "run_build_tool.sh" + def path = Paths.get(new File(pluginFile).parent, "..", executableName); + + def manifestDir = Paths.get(project.buildscript.sourceFile.parent, project.cargokit.manifestDir) + + def rootProjectDir = project.rootProject.projectDir + + if (!Os.isFamily(Os.FAMILY_WINDOWS)) { + project.exec { + commandLine 'chmod', '+x', path + } + } + + project.exec { + executable path + args "build-gradle" + environment "CARGOKIT_ROOT_PROJECT_DIR", rootProjectDir + environment "CARGOKIT_TOOL_TEMP_DIR", "${buildDir}/build_tool" + environment "CARGOKIT_MANIFEST_DIR", manifestDir + environment "CARGOKIT_CONFIGURATION", buildMode + environment "CARGOKIT_TARGET_TEMP_DIR", buildDir + environment "CARGOKIT_OUTPUT_DIR", outputDir + environment "CARGOKIT_NDK_VERSION", ndkVersion + environment "CARGOKIT_SDK_DIR", sdkDirectory + environment "CARGOKIT_COMPILE_SDK_VERSION", compileSdkVersion + environment "CARGOKIT_MIN_SDK_VERSION", minSdkVersion + environment "CARGOKIT_TARGET_PLATFORMS", targetPlatforms.join(",") + environment "CARGOKIT_JAVA_HOME", System.properties['java.home'] + } + } +} + +class CargoKitPlugin implements Plugin { + + static String file; + + private Plugin findFlutterPlugin(Project rootProject) { + _findFlutterPlugin(rootProject.childProjects) + } + + private Plugin _findFlutterPlugin(Map projects) { + for (project in projects) { + for (plugin in project.value.getPlugins()) { + if (plugin.class.name == "FlutterPlugin") { + return plugin; + } + } + def plugin = _findFlutterPlugin(project.value.childProjects); + if (plugin != null) { + return plugin; + } + } + return null; + } + + @Override + void apply(Project project) { + def plugin = findFlutterPlugin(project.rootProject); + + project.extensions.create("cargokit", CargoKitExtension) + + if (plugin == null) { + print("Flutter plugin not found, CargoKit plugin will not be applied.") + return; + } + + def cargoBuildDir = "${project.buildDir}/build" + + // Determine if the project is an application or library + def isApplication = plugin.project.plugins.hasPlugin('com.android.application') + def variants = isApplication ? plugin.project.android.applicationVariants : plugin.project.android.libraryVariants + + variants.all { variant -> + + final buildType = variant.buildType.name + + def cargoOutputDir = "${project.buildDir}/jniLibs/${buildType}"; + def jniLibs = project.android.sourceSets.maybeCreate(buildType).jniLibs; + jniLibs.srcDir(new File(cargoOutputDir)) + + def platforms = plugin.getTargetPlatforms().collect() + + // Same thing addFlutterDependencies does in flutter.gradle + if (buildType == "debug") { + platforms.add("android-x86") + platforms.add("android-x64") + } + + // The task name depends on plugin properties, which are not available + // at this point + project.getGradle().afterProject { + def taskName = "cargokitCargoBuild${project.cargokit.libname.capitalize()}${buildType.capitalize()}"; + + if (project.tasks.findByName(taskName)) { + return + } + + if (plugin.project.android.ndkVersion == null) { + throw new GradleException("Please set 'android.ndkVersion' in 'app/build.gradle'.") + } + + def task = project.tasks.create(taskName, CargoKitBuildTask.class) { + buildMode = variant.buildType.name + buildDir = cargoBuildDir + outputDir = cargoOutputDir + ndkVersion = plugin.project.android.ndkVersion + sdkDirectory = plugin.project.android.sdkDirectory + minSdkVersion = plugin.project.android.defaultConfig.minSdkVersion.apiLevel as int + compileSdkVersion = plugin.project.android.compileSdkVersion.substring(8) as int + targetPlatforms = platforms + pluginFile = CargoKitPlugin.file + } + def onTask = { newTask -> + if (newTask.name == "merge${buildType.capitalize()}NativeLibs") { + newTask.dependsOn task + // Fix gradle 7.4.2 not picking up JNI library changes + newTask.outputs.upToDateWhen { false } + } + } + project.tasks.each onTask + project.tasks.whenTaskAdded onTask + } + } + } +} diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/run_build_tool.cmd b/turms-chat-demo-flutter/rust_builder/cargokit/run_build_tool.cmd new file mode 100644 index 0000000000..c45d0aa8b5 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/run_build_tool.cmd @@ -0,0 +1,91 @@ +@echo off +setlocal + +setlocal ENABLEDELAYEDEXPANSION + +SET BASEDIR=%~dp0 + +if not exist "%CARGOKIT_TOOL_TEMP_DIR%" ( + mkdir "%CARGOKIT_TOOL_TEMP_DIR%" +) +cd /D "%CARGOKIT_TOOL_TEMP_DIR%" + +SET BUILD_TOOL_PKG_DIR=%BASEDIR%build_tool +SET DART=%FLUTTER_ROOT%\bin\cache\dart-sdk\bin\dart + +set BUILD_TOOL_PKG_DIR_POSIX=%BUILD_TOOL_PKG_DIR:\=/% + +( + echo name: build_tool_runner + echo version: 1.0.0 + echo publish_to: none + echo. + echo environment: + echo sdk: '^>=3.0.0 ^<4.0.0' + echo. + echo dependencies: + echo build_tool: + echo path: %BUILD_TOOL_PKG_DIR_POSIX% +) >pubspec.yaml + +if not exist bin ( + mkdir bin +) + +( + echo import 'package:build_tool/build_tool.dart' as build_tool; + echo void main^(List^ args^) ^{ + echo build_tool.runMain^(args^); + echo ^} +) >bin\build_tool_runner.dart + +SET PRECOMPILED=bin\build_tool_runner.dill + +REM To detect changes in package we compare output of DIR /s (recursive) +set PREV_PACKAGE_INFO=.dart_tool\package_info.prev +set CUR_PACKAGE_INFO=.dart_tool\package_info.cur + +DIR "%BUILD_TOOL_PKG_DIR%" /s > "%CUR_PACKAGE_INFO%_orig" + +REM Last line in dir output is free space on harddrive. That is bound to +REM change between invocation so we need to remove it +( + Set "Line=" + For /F "UseBackQ Delims=" %%A In ("%CUR_PACKAGE_INFO%_orig") Do ( + SetLocal EnableDelayedExpansion + If Defined Line Echo !Line! + EndLocal + Set "Line=%%A") +) >"%CUR_PACKAGE_INFO%" +DEL "%CUR_PACKAGE_INFO%_orig" + +REM Compare current directory listing with previous +FC /B "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" > nul 2>&1 + +If %ERRORLEVEL% neq 0 ( + REM Changed - copy current to previous and remove precompiled kernel + if exist "%PREV_PACKAGE_INFO%" ( + DEL "%PREV_PACKAGE_INFO%" + ) + MOVE /Y "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" + if exist "%PRECOMPILED%" ( + DEL "%PRECOMPILED%" + ) +) + +REM There is no CUR_PACKAGE_INFO it was renamed in previous step to %PREV_PACKAGE_INFO% +REM which means we need to do pub get and precompile +if not exist "%PRECOMPILED%" ( + echo Running pub get in "%cd%" + "%DART%" pub get --no-precompile + "%DART%" compile kernel bin/build_tool_runner.dart +) + +"%DART%" "%PRECOMPILED%" %* + +REM 253 means invalid snapshot version. +If %ERRORLEVEL% equ 253 ( + "%DART%" pub get --no-precompile + "%DART%" compile kernel bin/build_tool_runner.dart + "%DART%" "%PRECOMPILED%" %* +) diff --git a/turms-chat-demo-flutter/rust_builder/cargokit/run_build_tool.sh b/turms-chat-demo-flutter/rust_builder/cargokit/run_build_tool.sh new file mode 100644 index 0000000000..6e594a23d4 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/cargokit/run_build_tool.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +set -e + +BASEDIR=$(dirname "$0") + +mkdir -p "$CARGOKIT_TOOL_TEMP_DIR" + +cd "$CARGOKIT_TOOL_TEMP_DIR" + +# Write a very simple bin package in temp folder that depends on build_tool package +# from Cargokit. This is done to ensure that we don't pollute Cargokit folder +# with .dart_tool contents. + +BUILD_TOOL_PKG_DIR="$BASEDIR/build_tool" + +if [[ -z $FLUTTER_ROOT ]]; then # not defined + DART=dart +else + DART="$FLUTTER_ROOT/bin/cache/dart-sdk/bin/dart" +fi + +cat << EOF > "pubspec.yaml" +name: build_tool_runner +version: 1.0.0 +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + build_tool: + path: "$BUILD_TOOL_PKG_DIR" +EOF + +mkdir -p "bin" + +cat << EOF > "bin/build_tool_runner.dart" +import 'package:build_tool/build_tool.dart' as build_tool; +void main(List args) { + build_tool.runMain(args); +} +EOF + +# Create alias for `shasum` if it does not exist and `sha1sum` exists +if ! [ -x "$(command -v shasum)" ] && [ -x "$(command -v sha1sum)" ]; then + shopt -s expand_aliases + alias shasum="sha1sum" +fi + +# Dart run will not cache any package that has a path dependency, which +# is the case for our build_tool_runner. So instead we precompile the package +# ourselves. +# To invalidate the cached kernel we use the hash of ls -LR of the build_tool +# package directory. This should be good enough, as the build_tool package +# itself is not meant to have any path dependencies. + +if [[ "$OSTYPE" == "darwin"* ]]; then + PACKAGE_HASH=$(ls -lTR "$BUILD_TOOL_PKG_DIR" | shasum) +else + PACKAGE_HASH=$(ls -lR --full-time "$BUILD_TOOL_PKG_DIR" | shasum) +fi + +PACKAGE_HASH_FILE=".package_hash" + +if [ -f "$PACKAGE_HASH_FILE" ]; then + EXISTING_HASH=$(cat "$PACKAGE_HASH_FILE") + if [ "$PACKAGE_HASH" != "$EXISTING_HASH" ]; then + rm "$PACKAGE_HASH_FILE" + fi +fi + +# Run pub get if needed. +if [ ! -f "$PACKAGE_HASH_FILE" ]; then + "$DART" pub get --no-precompile + "$DART" compile kernel bin/build_tool_runner.dart + echo "$PACKAGE_HASH" > "$PACKAGE_HASH_FILE" +fi + +set +e + +"$DART" bin/build_tool_runner.dill "$@" + +exit_code=$? + +# 253 means invalid snapshot version. +if [ $exit_code == 253 ]; then + "$DART" pub get --no-precompile + "$DART" compile kernel bin/build_tool_runner.dart + "$DART" bin/build_tool_runner.dill "$@" + exit_code=$? +fi + +exit $exit_code diff --git a/turms-chat-demo-flutter/rust_builder/ios/Classes/dummy_file.c b/turms-chat-demo-flutter/rust_builder/ios/Classes/dummy_file.c new file mode 100644 index 0000000000..e06dab9968 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/ios/Classes/dummy_file.c @@ -0,0 +1 @@ +// This is an empty file to force CocoaPods to create a framework. diff --git a/turms-chat-demo-flutter/rust_builder/ios/rust_lib_turms_chat_demo.podspec b/turms-chat-demo-flutter/rust_builder/ios/rust_lib_turms_chat_demo.podspec new file mode 100644 index 0000000000..cfd8ee20e1 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/ios/rust_lib_turms_chat_demo.podspec @@ -0,0 +1,45 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint rust_lib_turms_chat_demo.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'rust_lib_turms_chat_demo' + s.version = '0.0.1' + s.summary = 'A new Flutter FFI plugin project.' + s.description = <<-DESC +A new Flutter FFI plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + + # This will ensure the source files in Classes/ are included in the native + # builds of apps using this FFI plugin. Podspec does not support relative + # paths, so Classes contains a forwarder C file that relatively imports + # `../src/*` so that the C sources can be shared among all target platforms. + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '11.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + s.script_phase = { + :name => 'Build Rust library', + # First argument is relative path to the `rust` folder, second is name of rust library + :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../../rust rust_lib_turms_chat_demo', + :execution_position => :before_compile, + :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'], + # Let XCode know that the static library referenced in -force_load below is + # created by this build step. + :output_files => ["${BUILT_PRODUCTS_DIR}/librust_lib_turms_chat_demo.a"], + } + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + # Flutter.framework does not contain a i386 slice. + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-force_load ${BUILT_PRODUCTS_DIR}/librust_lib_turms_chat_demo.a', + } +end \ No newline at end of file diff --git a/turms-chat-demo-flutter/rust_builder/linux/CMakeLists.txt b/turms-chat-demo-flutter/rust_builder/linux/CMakeLists.txt new file mode 100644 index 0000000000..0cbbd8d81e --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/linux/CMakeLists.txt @@ -0,0 +1,19 @@ +# The Flutter tooling requires that developers have CMake 3.10 or later +# installed. You should not increase this version, as doing so will cause +# the plugin to fail to compile for some customers of the plugin. +cmake_minimum_required(VERSION 3.10) + +# Project-level configuration. +set(PROJECT_NAME "rust_lib_turms_chat_demo") +project(${PROJECT_NAME} LANGUAGES CXX) + +include("../cargokit/cmake/cargokit.cmake") +apply_cargokit(${PROJECT_NAME} ../../rust rust_lib_turms_chat_demo "") + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(rust_lib_turms_chat_demo_bundled_libraries + "${${PROJECT_NAME}_cargokit_lib}" + PARENT_SCOPE +) diff --git a/turms-chat-demo-flutter/rust_builder/macos/Classes/dummy_file.c b/turms-chat-demo-flutter/rust_builder/macos/Classes/dummy_file.c new file mode 100644 index 0000000000..e06dab9968 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/macos/Classes/dummy_file.c @@ -0,0 +1 @@ +// This is an empty file to force CocoaPods to create a framework. diff --git a/turms-chat-demo-flutter/rust_builder/macos/rust_lib_turms_chat_demo.podspec b/turms-chat-demo-flutter/rust_builder/macos/rust_lib_turms_chat_demo.podspec new file mode 100644 index 0000000000..da594e387b --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/macos/rust_lib_turms_chat_demo.podspec @@ -0,0 +1,44 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint rust_lib_turms_chat_demo.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'rust_lib_turms_chat_demo' + s.version = '0.0.1' + s.summary = 'A new Flutter FFI plugin project.' + s.description = <<-DESC +A new Flutter FFI plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + + # This will ensure the source files in Classes/ are included in the native + # builds of apps using this FFI plugin. Podspec does not support relative + # paths, so Classes contains a forwarder C file that relatively imports + # `../src/*` so that the C sources can be shared among all target platforms. + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.11' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' + + s.script_phase = { + :name => 'Build Rust library', + # First argument is relative path to the `rust` folder, second is name of rust library + :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../../rust rust_lib_turms_chat_demo', + :execution_position => :before_compile, + :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'], + # Let XCode know that the static library referenced in -force_load below is + # created by this build step. + :output_files => ["${BUILT_PRODUCTS_DIR}/librust_lib_turms_chat_demo.a"], + } + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + # Flutter.framework does not contain a i386 slice. + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-force_load ${BUILT_PRODUCTS_DIR}/librust_lib_turms_chat_demo.a', + } +end \ No newline at end of file diff --git a/turms-chat-demo-flutter/rust_builder/pubspec.yaml b/turms-chat-demo-flutter/rust_builder/pubspec.yaml new file mode 100644 index 0000000000..a2b46fe0b4 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/pubspec.yaml @@ -0,0 +1,34 @@ +name: rust_lib_turms_chat_demo +description: "Utility to build Rust code" +version: 0.0.1 +publish_to: none + +environment: + sdk: '>=3.3.0 <4.0.0' + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + ffi: ^2.0.2 + ffigen: ^11.0.0 + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: + plugin: + platforms: + android: + ffiPlugin: true + ios: + ffiPlugin: true + linux: + ffiPlugin: true + macos: + ffiPlugin: true + windows: + ffiPlugin: true diff --git a/turms-chat-demo-flutter/rust_builder/windows/.gitignore b/turms-chat-demo-flutter/rust_builder/windows/.gitignore new file mode 100644 index 0000000000..b3eb2be169 --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/turms-chat-demo-flutter/rust_builder/windows/CMakeLists.txt b/turms-chat-demo-flutter/rust_builder/windows/CMakeLists.txt new file mode 100644 index 0000000000..bd53a2b33d --- /dev/null +++ b/turms-chat-demo-flutter/rust_builder/windows/CMakeLists.txt @@ -0,0 +1,20 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "rust_lib_turms_chat_demo") +project(${PROJECT_NAME} LANGUAGES CXX) + +include("../cargokit/cmake/cargokit.cmake") +apply_cargokit(${PROJECT_NAME} ../../../../../../rust rust_lib_turms_chat_demo "") + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(rust_lib_turms_chat_demo_bundled_libraries + "${${PROJECT_NAME}_cargokit_lib}" + PARENT_SCOPE +) diff --git a/turms-chat-demo-flutter/tool/setup.dart b/turms-chat-demo-flutter/tool/setup.dart new file mode 100644 index 0000000000..7c05702086 --- /dev/null +++ b/turms-chat-demo-flutter/tool/setup.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:turms_chat_demo/infra/built_in_types/built_in_type_helpers.dart'; + +class Task { + Task({required this.name, required this.executable, required this.arguments}); + + final String name; + final String executable; + final List arguments; +} + +Future runTask( + String taskName, String executable, List? arguments) async { + if (arguments == null || arguments.isEmpty) { + stdout.writeln("Start '$taskName' task. Running: $executable"); + } else { + stdout.writeln( + "Start '$taskName' task. Running: $executable ${arguments.join(' ')}"); + } + final process = + await Process.start(executable, arguments ?? [], runInShell: true); + process.stdout.transform(utf8.decoder).listen(stdout.write); + process.stderr.transform(utf8.decoder).listen(stderr.write); + + final exitCode = await process.exitCode; + if (exitCode == 0) { + stdout.writeln('Exit code: $exitCode'); + } else { + stderr.writeln('Exit code: $exitCode'); + } + await stdout.flush(); + await stderr.flush(); +} + +Future generateEnvFile() async { + final envFile = File('.env'); + final lines = envFile.readAsLinesSync(); + + final code = StringBuffer() + ..write('class EnvVars {\n') + ..write(' EnvVars._();\n\n'); + + for (final line in lines) { + if (line.isBlank || line.startsWith('#')) { + continue; + } + final parts = line.split('='); + final key = parts[0]; + final value = parts[1]; + final name = key.constCaseToCamelCase(); + if (value == 'true' || value == 'false') { + code.write(" static const bool $name = bool.fromEnvironment('$key');\n"); + } else if (value.contains('.') && double.tryParse(value) != null) { + code.write( + " static final double $name = double.parse(const String.fromEnvironment('$key'));\n"); + } else if (int.tryParse(value) != null) { + code.write(" static const int $name = int.fromEnvironment('$key');\n"); + } else { + code.write( + " static const String $name = String.fromEnvironment('$key');\n"); + } + } + code.write('}'); + final file = File('./lib/infra/env/env_vars.dart'); + await file.create(recursive: true); + await file.writeAsString(code.toString()); + print('Generated the environment variables file: ${file.absolute.path}'); +} + +Future main() async { + await generateEnvFile(); + final tasks = [ + Task( + name: 'generate l10n dart files', + executable: 'flutter', + arguments: ['gen-l10n']), + Task( + name: 'generate assets and database dart files', + executable: 'dart', + arguments: [ + 'run', + 'build_runner', + 'build', + '--delete-conflicting-outputs' + ]) + ]; + if (Platform.isLinux) { + // final linuxDeviceInfo = await DeviceInfoPlugin().linuxInfo; + // // https://pub.dev/packages/system_tray#prerequisite + // if (linuxDeviceInfo.id.toLowerCase() == 'ubuntu') { + // tasks.add(Task( + // name: 'install dependencies of system_tray', + // executable: 'sudo', + // arguments: ['apt-get', 'install', 'libayatana-appindicator3-dev'])); + // } else { + // tasks.add(Task( + // name: 'install dependencies of system_tray', + // executable: 'sudo', + // arguments: [ + // 'apt-get', + // 'install', + // 'appindicator3-0.1', + // 'libappindicator3-dev' + // ])); + // } + } + for (final task in tasks) { + await runTask(task.name, task.executable, task.arguments); + } +} diff --git a/turms-chat-demo-flutter/tool/setup_with_flutter.dart b/turms-chat-demo-flutter/tool/setup_with_flutter.dart new file mode 100644 index 0000000000..7cdf10c046 --- /dev/null +++ b/turms-chat-demo-flutter/tool/setup_with_flutter.dart @@ -0,0 +1,24 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter_svg/flutter_svg.dart'; + +Future svgToPng(Uint8List svg) async { + final pictureInfo = await vg.loadPicture(SvgBytesLoader(svg), null); + final image = await pictureInfo.picture.toImage(1024, 1024); + final byteData = await image.toByteData(format: ImageByteFormat.png); + if (byteData == null) { + throw Exception('Unable to convert SVG to PNG'); + } + return byteData.buffer.asUint8List(); +} + +Future main() async { + final iconFile = File.fromUri(Uri.parse('../assets/images/icon.svg')); + final iconBytes = await iconFile.readAsBytes(); + final iconPngBytes = await svgToPng(iconBytes); + final iconPngFile = + File.fromUri(Uri.parse('../assets/images/icon_rectangle.png')); + await iconPngFile.writeAsBytes(iconPngBytes); +} diff --git a/turms-chat-demo-flutter/web/index.html b/turms-chat-demo-flutter/web/index.html new file mode 100644 index 0000000000..1bcc00904d --- /dev/null +++ b/turms-chat-demo-flutter/web/index.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + turms_chat_demo + + + + + + + + + + diff --git a/turms-chat-demo-flutter/web/manifest.json b/turms-chat-demo-flutter/web/manifest.json new file mode 100644 index 0000000000..d326c581dc --- /dev/null +++ b/turms-chat-demo-flutter/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "turms_chat_demo", + "short_name": "turms_chat_demo", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/turms-chat-demo-flutter/windows/.gitignore b/turms-chat-demo-flutter/windows/.gitignore new file mode 100644 index 0000000000..d492d0d98c --- /dev/null +++ b/turms-chat-demo-flutter/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/turms-chat-demo-flutter/windows/CMakeLists.txt b/turms-chat-demo-flutter/windows/CMakeLists.txt new file mode 100644 index 0000000000..64cd2f89d6 --- /dev/null +++ b/turms-chat-demo-flutter/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(turms_chat_demo LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "turms_chat_demo") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) \ No newline at end of file diff --git a/turms-chat-demo-flutter/windows/flutter/CMakeLists.txt b/turms-chat-demo-flutter/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000000..903f4899d6 --- /dev/null +++ b/turms-chat-demo-flutter/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/turms-chat-demo-flutter/windows/flutter/generated_plugin_registrant.cc b/turms-chat-demo-flutter/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..a6d819e7a6 --- /dev/null +++ b/turms-chat-demo-flutter/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,62 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); + FlutterPlatformAlertPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterPlatformAlertPlugin")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + GalPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GalPluginCApi")); + IrondashEngineContextPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi")); + MediaKitLibsWindowsAudioPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsAudioPluginCApi")); + MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); + ScreenBrightnessWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenBrightnessWindowsPlugin")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + Sqlite3FlutterLibsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); + SuperNativeExtensionsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); + TrayManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("TrayManagerPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); + WindowsNotificationPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowsNotificationPluginCApi")); +} diff --git a/turms-chat-demo-flutter/windows/flutter/generated_plugin_registrant.h b/turms-chat-demo-flutter/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..dc139d85a9 --- /dev/null +++ b/turms-chat-demo-flutter/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/turms-chat-demo-flutter/windows/flutter/generated_plugins.cmake b/turms-chat-demo-flutter/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..f4efe71ba0 --- /dev/null +++ b/turms-chat-demo-flutter/windows/flutter/generated_plugins.cmake @@ -0,0 +1,42 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows + flutter_inappwebview_windows + flutter_platform_alert + flutter_secure_storage_windows + gal + irondash_engine_context + media_kit_libs_windows_audio + media_kit_libs_windows_video + media_kit_video + screen_brightness_windows + screen_retriever_windows + sqlite3_flutter_libs + super_native_extensions + tray_manager + url_launcher_windows + window_manager + windows_notification +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + media_kit_native_event_loop + rust_lib_turms_chat_demo +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/turms-chat-demo-flutter/windows/runner/CMakeLists.txt b/turms-chat-demo-flutter/windows/runner/CMakeLists.txt new file mode 100644 index 0000000000..394917c053 --- /dev/null +++ b/turms-chat-demo-flutter/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/turms-chat-demo-flutter/windows/runner/Runner.rc b/turms-chat-demo-flutter/windows/runner/Runner.rc new file mode 100644 index 0000000000..cc9b818b3f --- /dev/null +++ b/turms-chat-demo-flutter/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "im.turms" "\0" + VALUE "FileDescription", "turms_chat_demo" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "turms_chat_demo" "\0" + VALUE "LegalCopyright", "Copyright (C) 2024 im.turms. All rights reserved." "\0" + VALUE "OriginalFilename", "turms_chat_demo.exe" "\0" + VALUE "ProductName", "turms_chat_demo" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/turms-chat-demo-flutter/windows/runner/app_host_api.g.cpp b/turms-chat-demo-flutter/windows/runner/app_host_api.g.cpp new file mode 100644 index 0000000000..43dd9ba28b --- /dev/null +++ b/turms-chat-demo-flutter/windows/runner/app_host_api.g.cpp @@ -0,0 +1,167 @@ +// Autogenerated from Pigeon (v17.1.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +#undef _HAS_EXCEPTIONS + +#include "app_host_api.g.h" + +#include +#include +#include +#include + +#include +#include +#include + +namespace turms_chat_demo { +using flutter::BasicMessageChannel; +using flutter::CustomEncodableValue; +using flutter::EncodableList; +using flutter::EncodableMap; +using flutter::EncodableValue; + +FlutterError CreateConnectionError(const std::string channel_name) { + return FlutterError( + "channel-error", + "Unable to establish connection on channel: '" + channel_name + "'.", + EncodableValue("")); +} + +// DiskSpaceInfo + +DiskSpaceInfo::DiskSpaceInfo( + int64_t total, + int64_t free, + int64_t usable) + : total_(total), + free_(free), + usable_(usable) {} + +int64_t DiskSpaceInfo::total() const { + return total_; +} + +void DiskSpaceInfo::set_total(int64_t value_arg) { + total_ = value_arg; +} + + +int64_t DiskSpaceInfo::free() const { + return free_; +} + +void DiskSpaceInfo::set_free(int64_t value_arg) { + free_ = value_arg; +} + + +int64_t DiskSpaceInfo::usable() const { + return usable_; +} + +void DiskSpaceInfo::set_usable(int64_t value_arg) { + usable_ = value_arg; +} + + +EncodableList DiskSpaceInfo::ToEncodableList() const { + EncodableList list; + list.reserve(3); + list.push_back(EncodableValue(total_)); + list.push_back(EncodableValue(free_)); + list.push_back(EncodableValue(usable_)); + return list; +} + +DiskSpaceInfo DiskSpaceInfo::FromEncodableList(const EncodableList& list) { + DiskSpaceInfo decoded( + list[0].LongValue(), + list[1].LongValue(), + list[2].LongValue()); + return decoded; +} + + +AppHostApiCodecSerializer::AppHostApiCodecSerializer() {} + +EncodableValue AppHostApiCodecSerializer::ReadValueOfType( + uint8_t type, + flutter::ByteStreamReader* stream) const { + switch (type) { + case 128: + return CustomEncodableValue(DiskSpaceInfo::FromEncodableList(std::get(ReadValue(stream)))); + default: + return flutter::StandardCodecSerializer::ReadValueOfType(type, stream); + } +} + +void AppHostApiCodecSerializer::WriteValue( + const EncodableValue& value, + flutter::ByteStreamWriter* stream) const { + if (const CustomEncodableValue* custom_value = std::get_if(&value)) { + if (custom_value->type() == typeid(DiskSpaceInfo)) { + stream->WriteByte(128); + WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); + return; + } + } + flutter::StandardCodecSerializer::WriteValue(value, stream); +} + +/// The codec used by AppHostApi. +const flutter::StandardMessageCodec& AppHostApi::GetCodec() { + return flutter::StandardMessageCodec::GetInstance(&AppHostApiCodecSerializer::GetInstance()); +} + +// Sets up an instance of `AppHostApi` to handle messages through the `binary_messenger`. +void AppHostApi::SetUp( + flutter::BinaryMessenger* binary_messenger, + AppHostApi* api) { + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.turms_chat_demo.AppHostApi.getDiskSpace", &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_path_arg = args.at(0); + if (encodable_path_arg.IsNull()) { + reply(WrapError("path_arg unexpectedly null.")); + return; + } + const auto& path_arg = std::get(encodable_path_arg); + ErrorOr output = api->GetDiskSpace(path_arg); + if (output.has_error()) { + reply(WrapError(output.error())); + return; + } + EncodableList wrapped; + wrapped.push_back(CustomEncodableValue(std::move(output).TakeValue())); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } +} + +EncodableValue AppHostApi::WrapError(std::string_view error_message) { + return EncodableValue(EncodableList{ + EncodableValue(std::string(error_message)), + EncodableValue("Error"), + EncodableValue() + }); +} + +EncodableValue AppHostApi::WrapError(const FlutterError& error) { + return EncodableValue(EncodableList{ + EncodableValue(error.code()), + EncodableValue(error.message()), + error.details() + }); +} + +} // namespace turms_chat_demo diff --git a/turms-chat-demo-flutter/windows/runner/app_host_api.g.h b/turms-chat-demo-flutter/windows/runner/app_host_api.g.h new file mode 100644 index 0000000000..32a1c35d8d --- /dev/null +++ b/turms-chat-demo-flutter/windows/runner/app_host_api.g.h @@ -0,0 +1,130 @@ +// Autogenerated from Pigeon (v17.1.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +#ifndef PIGEON_APP_HOST_API_G_H_ +#define PIGEON_APP_HOST_API_G_H_ +#include +#include +#include +#include + +#include +#include +#include + +namespace turms_chat_demo { + + +// Generated class from Pigeon. + +class FlutterError { + public: + explicit FlutterError(const std::string& code) + : code_(code) {} + explicit FlutterError(const std::string& code, const std::string& message) + : code_(code), message_(message) {} + explicit FlutterError(const std::string& code, const std::string& message, const flutter::EncodableValue& details) + : code_(code), message_(message), details_(details) {} + + const std::string& code() const { return code_; } + const std::string& message() const { return message_; } + const flutter::EncodableValue& details() const { return details_; } + + private: + std::string code_; + std::string message_; + flutter::EncodableValue details_; +}; + +template class ErrorOr { + public: + ErrorOr(const T& rhs) : v_(rhs) {} + ErrorOr(const T&& rhs) : v_(std::move(rhs)) {} + ErrorOr(const FlutterError& rhs) : v_(rhs) {} + ErrorOr(const FlutterError&& rhs) : v_(std::move(rhs)) {} + + bool has_error() const { return std::holds_alternative(v_); } + const T& value() const { return std::get(v_); }; + const FlutterError& error() const { return std::get(v_); }; + + private: + friend class AppHostApi; + ErrorOr() = default; + T TakeValue() && { return std::get(std::move(v_)); } + + std::variant v_; +}; + + +// Generated class from Pigeon that represents data sent in messages. +class DiskSpaceInfo { + public: + // Constructs an object setting all fields. + explicit DiskSpaceInfo( + int64_t total, + int64_t free, + int64_t usable); + + int64_t total() const; + void set_total(int64_t value_arg); + + int64_t free() const; + void set_free(int64_t value_arg); + + int64_t usable() const; + void set_usable(int64_t value_arg); + + + private: + static DiskSpaceInfo FromEncodableList(const flutter::EncodableList& list); + flutter::EncodableList ToEncodableList() const; + friend class AppHostApi; + friend class AppHostApiCodecSerializer; + int64_t total_; + int64_t free_; + int64_t usable_; + +}; + +class AppHostApiCodecSerializer : public flutter::StandardCodecSerializer { + public: + AppHostApiCodecSerializer(); + inline static AppHostApiCodecSerializer& GetInstance() { + static AppHostApiCodecSerializer sInstance; + return sInstance; + } + + void WriteValue( + const flutter::EncodableValue& value, + flutter::ByteStreamWriter* stream) const override; + + protected: + flutter::EncodableValue ReadValueOfType( + uint8_t type, + flutter::ByteStreamReader* stream) const override; + +}; + +// Generated interface from Pigeon that represents a handler of messages from Flutter. +class AppHostApi { + public: + AppHostApi(const AppHostApi&) = delete; + AppHostApi& operator=(const AppHostApi&) = delete; + virtual ~AppHostApi() {} + virtual ErrorOr GetDiskSpace(const std::string& path) = 0; + + // The codec used by AppHostApi. + static const flutter::StandardMessageCodec& GetCodec(); + // Sets up an instance of `AppHostApi` to handle messages through the `binary_messenger`. + static void SetUp( + flutter::BinaryMessenger* binary_messenger, + AppHostApi* api); + static flutter::EncodableValue WrapError(std::string_view error_message); + static flutter::EncodableValue WrapError(const FlutterError& error); + + protected: + AppHostApi() = default; + +}; +} // namespace turms_chat_demo +#endif // PIGEON_APP_HOST_API_G_H_ diff --git a/turms-chat-demo-flutter/windows/runner/flutter_window.cpp b/turms-chat-demo-flutter/windows/runner/flutter_window.cpp new file mode 100644 index 0000000000..613a66b784 --- /dev/null +++ b/turms-chat-demo-flutter/windows/runner/flutter_window.cpp @@ -0,0 +1,67 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/turms-chat-demo-flutter/windows/runner/flutter_window.h b/turms-chat-demo-flutter/windows/runner/flutter_window.h new file mode 100644 index 0000000000..6da0652f05 --- /dev/null +++ b/turms-chat-demo-flutter/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/turms-chat-demo-flutter/windows/runner/main.cpp b/turms-chat-demo-flutter/windows/runner/main.cpp new file mode 100644 index 0000000000..2a0ea0a2d0 --- /dev/null +++ b/turms-chat-demo-flutter/windows/runner/main.cpp @@ -0,0 +1,79 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +class SingleInstanceMutex { + public: + explicit SingleInstanceMutex(LPCWSTR mutex_name) { + h_mutex_ = CreateMutexW(nullptr, FALSE, mutex_name); + dw_last_error_ = GetLastError(); + } + + ~SingleInstanceMutex() { + if (h_mutex_) { + CloseHandle(h_mutex_); + h_mutex_ = nullptr; + } + } + + [[nodiscard]] BOOL IsOtherInstanceRunning() const { + return (ERROR_ALREADY_EXISTS == dw_last_error_); + } + + protected: + DWORD dw_last_error_; + HANDLE h_mutex_; +}; + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Used to fix https://github.com/flutter/flutter/issues/119251 + SystemParametersInfo(SPI_SETBEEP, FALSE, NULL, SPIF_SENDCHANGE); + + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + // Create a single instance mutex to prevent multiple instances. + SingleInstanceMutex single_instance_mutex(L"turms_chat_demo_single_instance_mutex"); + if (single_instance_mutex.IsOtherInstanceRunning()) { + HWND existingApp = FindWindow(nullptr, L"turms_chat_demo"); + if (existingApp) { + SetForegroundWindow(existingApp); + } + return EXIT_FAILURE; + } + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"turms_chat_demo", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} \ No newline at end of file diff --git a/turms-chat-demo-flutter/windows/runner/resource.h b/turms-chat-demo-flutter/windows/runner/resource.h new file mode 100644 index 0000000000..66a65d1e4a --- /dev/null +++ b/turms-chat-demo-flutter/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/turms-chat-demo-flutter/windows/runner/runner.exe.manifest b/turms-chat-demo-flutter/windows/runner/runner.exe.manifest new file mode 100644 index 0000000000..a42ea7687c --- /dev/null +++ b/turms-chat-demo-flutter/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/turms-chat-demo-flutter/windows/runner/utils.cpp b/turms-chat-demo-flutter/windows/runner/utils.cpp new file mode 100644 index 0000000000..b2b08734db --- /dev/null +++ b/turms-chat-demo-flutter/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length <= 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/turms-chat-demo-flutter/windows/runner/utils.h b/turms-chat-demo-flutter/windows/runner/utils.h new file mode 100644 index 0000000000..3879d54755 --- /dev/null +++ b/turms-chat-demo-flutter/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/turms-chat-demo-flutter/windows/runner/win32_window.cpp b/turms-chat-demo-flutter/windows/runner/win32_window.cpp new file mode 100644 index 0000000000..60608d0fe5 --- /dev/null +++ b/turms-chat-demo-flutter/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/turms-chat-demo-flutter/windows/runner/win32_window.h b/turms-chat-demo-flutter/windows/runner/win32_window.h new file mode 100644 index 0000000000..e901dde684 --- /dev/null +++ b/turms-chat-demo-flutter/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_