diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 000000000..3820a95c6 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +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 +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/example/.metadata b/example/.metadata index 706ff779c..2eaf67a03 100644 --- a/example/.metadata +++ b/example/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "603104015dd692ea3403755b55d07813d5cf8965" + revision: "a402d9a4376add5bc2d6b1e33e53edaae58c07f8" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 603104015dd692ea3403755b55d07813d5cf8965 - base_revision: 603104015dd692ea3403755b55d07813d5cf8965 - - platform: android - create_revision: 603104015dd692ea3403755b55d07813d5cf8965 - base_revision: 603104015dd692ea3403755b55d07813d5cf8965 + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: macos + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 # User provided section diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 132ab7417..53933356e 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -8,7 +8,7 @@ plugins { android { namespace = "com.zebra.zeta.example" compileSdk = flutter.compileSdkVersion - ndkVersion = "25.1.8937393" + ndkVersion = "27.0.12077973" compileOptions { sourceCompatibility JavaVersion.VERSION_17 @@ -24,7 +24,7 @@ android { applicationId = "com.zebra.zeta.example" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = 23 + minSdkVersion flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 74a78b939..af1f02175 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -17,9 +17,9 @@ while the Flutter UI initializes. After that, this theme continues to determine the Window background behind the Flutter UI. --> + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" + /> @@ -41,5 +41,8 @@ + + + diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 9c5194d3a..d5ce57cba 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip \ No newline at end of file diff --git a/example/android/settings.gradle b/example/android/settings.gradle index a00f9ea70..4f520718d 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.3.1" apply false - id "org.jetbrains.kotlin.android" version "1.8.22" apply false + id "com.android.application" version "8.6.0" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false } include ":app" diff --git a/example/lib/home.dart b/example/lib/home.dart index b4063d465..0dab3124a 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -24,6 +24,7 @@ import 'package:zeta_example/pages/components/global_header_example.dart'; import 'package:zeta_example/pages/components/filter_selection_example.dart'; import 'package:zeta_example/pages/components/list_example.dart'; import 'package:zeta_example/pages/components/list_item_example.dart'; +import 'package:zeta_example/pages/components/message_input_example.dart'; import 'package:zeta_example/pages/components/navigation_bar_example.dart'; import 'package:zeta_example/pages/components/navigation_rail_example.dart'; import 'package:zeta_example/pages/components/notification_list_example.dart'; @@ -32,7 +33,6 @@ import 'package:zeta_example/pages/components/radio_example.dart'; import 'package:zeta_example/pages/components/range_selector_example.dart'; import 'package:zeta_example/pages/components/screen_header_bar_example.dart'; import 'package:zeta_example/pages/components/select_input_example.dart'; - import 'package:zeta_example/pages/components/search_bar_example.dart'; import 'package:zeta_example/pages/components/segmented_control_example.dart'; import 'package:zeta_example/pages/components/slider_example.dart'; @@ -97,6 +97,7 @@ final List components = [ Component(Label.name, (context) => const Label()), Component(ListExample.name, (context) => const ListExample()), Component(ListItemExample.name, (context) => const ListItemExample()), + Component(MessageInputExample.name, (context) => const MessageInputExample()), Component(NavigationBarExample.name, (context) => const NavigationBarExample()), Component(NavigationRailExample.name, (context) => const NavigationRailExample()), Component(NotificationListItemExample.name, (context) => const NotificationListItemExample()), diff --git a/example/lib/pages/components/message_input_example.dart b/example/lib/pages/components/message_input_example.dart new file mode 100644 index 000000000..e4343be91 --- /dev/null +++ b/example/lib/pages/components/message_input_example.dart @@ -0,0 +1,84 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class MessageInputExample extends StatefulWidget { + static const String name = 'MessageInput'; + + const MessageInputExample({super.key}); + + @override + State createState() => _MessageInputExampleState(); +} + +class _MessageInputExampleState extends State { + late TextEditingController controller; + List messages = []; + List attachments = []; + + @override + void initState() { + super.initState(); + controller = TextEditingController(); + messages = [for (var i = 0; i < 3; i++) 'Message $i']; + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ExampleScaffold( + name: MessageInputExample.name, + child: Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: messages.length, + itemBuilder: (context, index) { + return ListTile( + title: Text(messages[index]), + ); + }, + ), + ), + ZetaMessageInput( + controller: controller, + maxVoiceMemoDuration: Duration(seconds: 10), + disabled: false, + allowsVoiceInput: true, + cameraTrailingButton: true, + hasActionMenu: true, + onSendLocation: (location) => setState(() => messages.add('Location: $location')), + onSendVoiceMemo: (file, bytes) => setState(() { + messages.add('Voice memo: ${file.path}'); + }), + attachments: attachments, + onSendAttachments: (fileList) { + fileList.forEach((file) { + setState(() { + messages.add('File attachment: ${file.path}'); + }); + }); + attachments.clear(); + }, + onSend: (message) { + if (message.isNotEmpty) { + setState(() { + messages.add(message); + }); + print('Message sent: $message'); + controller.clear(); + } + }, + ), + ], + ), + ); + } +} diff --git a/example/linux/flutter/generated_plugin_registrant.cc b/example/linux/flutter/generated_plugin_registrant.cc index 145c9eb8f..64e03628a 100644 --- a/example/linux/flutter/generated_plugin_registrant.cc +++ b/example/linux/flutter/generated_plugin_registrant.cc @@ -7,12 +7,16 @@ #include "generated_plugin_registrant.h" #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); + 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) record_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); record_linux_plugin_register_with_registrar(record_linux_registrar); diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake index ecad9e4f0..081622ccd 100644 --- a/example/linux/flutter/generated_plugins.cmake +++ b/example/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux + file_selector_linux record_linux ) diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 3ec2482bd..cecacbf56 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,15 +6,23 @@ import FlutterMacOS import Foundation import audioplayers_darwin +import file_picker +import file_selector_macos +import location import path_provider_foundation import record_macos import shared_preferences_foundation +import speech_to_text import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + LocationPlugin.register(with: registry.registrar(forPlugin: "LocationPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/example/macos/Podfile b/example/macos/Podfile index 9ec46f8cd..95a1c558a 100644 --- a/example/macos/Podfile +++ b/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.15' +platform :osx, '11.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -28,7 +28,6 @@ flutter_macos_podfile_setup target 'Runner' do use_frameworks! - use_modular_headers! flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) end diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index d395d6faf..8aa1e35cc 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -2,7 +2,16 @@ PODS: - audioplayers_darwin (0.0.1): - Flutter - FlutterMacOS + - CwlCatchException (2.2.1): + - CwlCatchExceptionSupport (~> 2.2.1) + - CwlCatchExceptionSupport (2.2.1) + - file_picker (0.0.1): + - FlutterMacOS + - file_selector_macos (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) + - location (0.0.1): + - FlutterMacOS - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -11,40 +20,67 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - speech_to_text (7.2.0): + - CwlCatchException + - Flutter + - FlutterMacOS - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS DEPENDENCIES: - audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`) + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) + - location (from `Flutter/ephemeral/.symlinks/plugins/location/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - speech_to_text (from `Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) +SPEC REPOS: + trunk: + - CwlCatchException + - CwlCatchExceptionSupport + EXTERNAL SOURCES: audioplayers_darwin: :path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos FlutterMacOS: :path: Flutter/ephemeral + location: + :path: Flutter/ephemeral/.symlinks/plugins/location/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin record_macos: :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + speech_to_text: + :path: Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin sqflite_darwin: :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin SPEC CHECKSUMS: audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923 + CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a + CwlCatchExceptionSupport: 54ccab8d8c78907b57f99717fb19d4cc3bce02dc + file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af + file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + location: 7cdb0665bd6577d382b0a343acdadbcb7f964775 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 record_macos: 4440ca269ad3b870ebb1965297a365d558f0c520 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + speech_to_text: 87bf9298952e8d9073be1b6aade6d5758db5170c sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d -PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0 +PODFILE CHECKSUM: 849a35e86ba3f6dc9bc81afd485a3818b12a749b COCOAPODS: 1.16.2 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index 7ec3408c9..3ffa51b9f 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -21,15 +21,23 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 22F494A5B8235393D2216CBD /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A9DFD1E57507D81D51F5C956 /* Pods_Runner.framework */; }; + 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 */; }; - 4D99090773193CBDD11097DB /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B3B47356FD6E66B2FD2113C /* Pods_Runner.framework */; }; /* 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 */; @@ -53,9 +61,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0ADC580AF06725DD2349DD02 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 2FFD83BC5BB3D408A567DF21 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 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 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* zeta_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = zeta_example.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 = ""; }; @@ -67,26 +79,39 @@ 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 = ""; }; - 3B3B47356FD6E66B2FD2113C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 775BFA427E635DE4CCD15022 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 89C2799490DE736CE6C6B909 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - C8838B380443E31AB7DE4089 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + A9DFD1E57507D81D51F5C956 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B77B24D6B05FA15CF19D12A8 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 33CC10EA2044A3C60003C045 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4D99090773193CBDD11097DB /* Pods_Runner.framework in Frameworks */, + 22F494A5B8235393D2216CBD /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; 33BA886A226E78AF003329D5 /* Configs */ = { isa = PBXGroup; children = ( @@ -103,16 +128,18 @@ children = ( 33FAB671232836740065AC1E /* Runner */, 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, - 5084074E164045F316413879 /* Pods */, + B37A337AA32146B1306E042D /* Pods */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* example.app */, + 33CC10ED2044A3C60003C045 /* zeta_example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -152,20 +179,21 @@ path = Runner; sourceTree = ""; }; - 5084074E164045F316413879 /* Pods */ = { + B37A337AA32146B1306E042D /* Pods */ = { isa = PBXGroup; children = ( - C8838B380443E31AB7DE4089 /* Pods-Runner.debug.xcconfig */, - 775BFA427E635DE4CCD15022 /* Pods-Runner.release.xcconfig */, - 89C2799490DE736CE6C6B909 /* Pods-Runner.profile.xcconfig */, + 2FFD83BC5BB3D408A567DF21 /* Pods-Runner.debug.xcconfig */, + 0ADC580AF06725DD2349DD02 /* Pods-Runner.release.xcconfig */, + B77B24D6B05FA15CF19D12A8 /* Pods-Runner.profile.xcconfig */, ); + name = Pods; path = Pods; sourceTree = ""; }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( - 3B3B47356FD6E66B2FD2113C /* Pods_Runner.framework */, + A9DFD1E57507D81D51F5C956 /* Pods_Runner.framework */, ); name = Frameworks; sourceTree = ""; @@ -173,17 +201,35 @@ /* 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 = ( - 3BECC061C0ABEC8462CAC898 /* [CP] Check Pods Manifest.lock */, + AE2B29F0544898D70F318103 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - BB66DD328FCDFBAE60FE60D4 /* [CP] Embed Pods Frameworks */, + E440B2AFF153D986F06C9B52 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -192,7 +238,7 @@ ); name = Runner; productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productReference = 33CC10ED2044A3C60003C045 /* zeta_example.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -201,10 +247,15 @@ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0920; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; 33CC10EC2044A3C60003C045 = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1100; @@ -235,12 +286,20 @@ 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; @@ -291,7 +350,7 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 3BECC061C0ABEC8462CAC898 /* [CP] Check Pods Manifest.lock */ = { + AE2B29F0544898D70F318103 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -313,7 +372,7 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - BB66DD328FCDFBAE60FE60D4 /* [CP] Embed Pods Frameworks */ = { + E440B2AFF153D986F06C9B52 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -333,6 +392,14 @@ /* 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; @@ -346,6 +413,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; @@ -366,11 +438,54 @@ /* 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 = com.zebra.zeta.zetaExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/zeta_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/zeta_example"; + }; + 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 = com.zebra.zeta.zetaExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/zeta_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/zeta_example"; + }; + 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 = com.zebra.zeta.zetaExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/zeta_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/zeta_example"; + }; + name = Profile; + }; 338D0CE9231458BD00FA5F75 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -394,9 +509,11 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -404,7 +521,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -444,6 +561,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -467,9 +585,11 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -483,7 +603,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -497,6 +617,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -520,9 +641,11 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -530,7 +653,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -596,6 +719,16 @@ /* 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 = ( diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 12076c83d..edd7f1194 100644 --- a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -31,12 +31,23 @@ + + + + @@ -72,7 +83,7 @@ diff --git a/example/macos/Runner/Configs/AppInfo.xcconfig b/example/macos/Runner/Configs/AppInfo.xcconfig index 871a41aeb..861d14e26 100644 --- a/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/example/macos/Runner/Configs/AppInfo.xcconfig @@ -5,10 +5,10 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = example +PRODUCT_NAME = zeta_example // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.zebra.zeta.example +PRODUCT_BUNDLE_IDENTIFIER = com.zebra.zeta.zetaExample // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2023 com.zebra.zeta. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2025 com.zebra.zeta. All rights reserved. diff --git a/example/macos/Runner/MainFlutterWindow.swift b/example/macos/Runner/MainFlutterWindow.swift index 2722837ec..3cc05eb23 100644 --- a/example/macos/Runner/MainFlutterWindow.swift +++ b/example/macos/Runner/MainFlutterWindow.swift @@ -3,7 +3,7 @@ import FlutterMacOS class MainFlutterWindow: NSWindow { override func awakeFromNib() { - let flutterViewController = FlutterViewController.init() + let flutterViewController = FlutterViewController() let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) diff --git a/example/macos/Runner/Release.entitlements b/example/macos/Runner/Release.entitlements index 90f6b188a..51bca823d 100644 --- a/example/macos/Runner/Release.entitlements +++ b/example/macos/Runner/Release.entitlements @@ -6,6 +6,6 @@ com.apple.security.device.audio-input - + diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc index c566a56a3..5e0768b78 100644 --- a/example/windows/flutter/generated_plugin_registrant.cc +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,17 @@ #include "generated_plugin_registrant.h" #include +#include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { AudioplayersWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); RecordWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); + SpeechToTextWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SpeechToTextWindows")); } diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake index 045e16c1d..5388ac024 100644 --- a/example/windows/flutter/generated_plugins.cmake +++ b/example/windows/flutter/generated_plugins.cmake @@ -4,7 +4,9 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows + file_selector_windows record_windows + speech_to_text_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/packages/zeta_flutter/lib/src/components/message_input/actions/action_button.dart b/packages/zeta_flutter/lib/src/components/message_input/actions/action_button.dart new file mode 100644 index 000000000..f53856342 --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/actions/action_button.dart @@ -0,0 +1,71 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../zeta_flutter.dart'; + +/// Action button for the action menu in the message input +class ActionButton extends ZetaStatelessWidget { + /// Creates an [ActionButton]. + const ActionButton({ + super.key, + required this.icon, + this.onPressed, + this.onLongPress, + this.color, + this.disabled, + this.size, + this.semanticLabel, + }); + + /// The icon to display. + final IconData icon; + + /// The callback invoked when the button is pressed. + final VoidCallback? onPressed; + + /// The callback invoked when the button is long pressed. + final VoidCallback? onLongPress; + + /// The color of the icon. Defaults to mainDefault. + final Color? color; + + /// Whether or not the button is disabled. + final bool? disabled; + + /// A size override. + final double? size; + + /// The semantic label on the icon button (used for screen readers). + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final ZetaColors colors = Zeta.of(context).colors; + final ZetaSpacing spacing = Zeta.of(context).spacing; + + return Semantics( + label: semanticLabel, + child: IconButton( + icon: Icon( + icon, + color: color ?? (onPressed == null || (disabled ?? false) ? colors.mainDisabled : colors.mainDefault), + size: size ?? spacing.xl_3, + ), + onPressed: (disabled ?? false) ? null : onPressed, + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('icon', icon)) + ..add(ObjectFlagProperty.has('callback', onPressed)) + ..add(ColorProperty('color', color)) + ..add(ObjectFlagProperty.has('onLongPressed', onLongPress)) + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(StringProperty('semanticLabel', semanticLabel)) + ..add(DoubleProperty('size', size)); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/actions/attachment_button.dart b/packages/zeta_flutter/lib/src/components/message_input/actions/attachment_button.dart new file mode 100644 index 000000000..80ddc78dc --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/actions/attachment_button.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../zeta_flutter.dart'; +import 'action_button.dart'; + +/// Attachment button for picking any type of file +class AttachmentButton extends ZetaStatelessWidget { + /// Creates an [AttachmentButton]. + const AttachmentButton({ + super.key, + this.onAttach, + this.allowedExtensions, + this.allowMultiple = false, + this.maxSize, + }); + + /// Callback for when files are selected. + final ValueChanged>? onAttach; + + /// Optional list of allowed file extensions (e.g., ['pdf', 'doc', 'docx']). + /// If null, all file types are allowed. + final List? allowedExtensions; + + /// Whether to allow selecting multiple files at once. + final bool allowMultiple; + + /// The maximum size of attachments in bytes. + final int? maxSize; + + Future _pickFiles(BuildContext context) async { + // final scaffoldMessenger = ScaffoldMessenger.of(context); + try { + final FilePickerResult? result = await FilePicker.platform.pickFiles( + type: allowedExtensions != null ? FileType.custom : FileType.any, + allowedExtensions: allowedExtensions, + allowMultiple: allowMultiple, + ); + + if (result != null) { + var files = result.paths.where((String? path) => path != null).map((String? path) => File(path!)).toList(); + // Filter by maxSize if specified + if (maxSize != null) { + files = files.where((file) { + try { + return file.lengthSync() <= maxSize!; + } catch (_) { + return false; + } + }).toList(); + } + if (files.isNotEmpty && onAttach != null) { + onAttach!(files); + } else if (result.paths.isNotEmpty && files.isEmpty) { + if (context.mounted) { + _displayError(context, 'Selected file(s) exceed maximum size of ${maxSize! / 1000000}MB'); + } + } + } + } catch (e) { + if (context.mounted) _displayError(context, e); + } + } + + void _displayError(BuildContext context, Object error) { + ScaffoldMessenger.of(context).showSnackBar( + ZetaSnackBar( + content: Text('Error picking files: $error'), + type: ZetaSnackBarType.error, + context: context, + ), + ); + } + + @override + Widget build(BuildContext context) { + return ActionButton( + icon: ZetaIcons.attachment, + onPressed: onAttach != null ? () => _pickFiles(context) : null, + semanticLabel: 'add an attachment', + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ObjectFlagProperty>>.has('onAttach', onAttach)) + ..add(IterableProperty('allowedExtensions', allowedExtensions)) + ..add(FlagProperty('allowMultiple', value: allowMultiple, ifTrue: 'multiple files allowed')); + properties.add(IntProperty('maxSize', maxSize)); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/actions/image_button.dart b/packages/zeta_flutter/lib/src/components/message_input/actions/image_button.dart new file mode 100644 index 000000000..9590450d2 --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/actions/image_button.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../../zeta_flutter.dart'; +import 'action_button.dart'; + +/// Camera button for capturing images +class ImageButton extends ZetaStatelessWidget { + /// Creates a [ImageButton]. + const ImageButton({ + super.key, + this.onCapture, + this.disabled, + }); + + /// Callback for when an image is captured. + final ValueChanged? onCapture; + + /// Wether or not the button is disabled. + final bool? disabled; + + Future _pickImage(BuildContext context) async { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage(source: ImageSource.camera, imageQuality: 80); + if (image != null && onCapture != null) { + onCapture!(File(image.path)); + } + } + + @override + Widget build(BuildContext context) { + return ActionButton( + icon: ZetaIcons.camera, + onPressed: () => _pickImage(context), + disabled: disabled, + semanticLabel: 'capture an image', + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ObjectFlagProperty>.has('onCapture', onCapture)) + ..add(DiagnosticsProperty('disabled', disabled)); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/actions/location_button.dart b/packages/zeta_flutter/lib/src/components/message_input/actions/location_button.dart new file mode 100644 index 000000000..c6a497840 --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/actions/location_button.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:location/location.dart'; + +import '../../../../zeta_flutter.dart'; +import 'action_button.dart'; + +/// Location button for sending precise or approximate location +class LocationButton extends ZetaStatelessWidget { + /// Creates a [LocationButton]. + const LocationButton({ + super.key, + this.onLocationCapture, + }); + + /// Callback for when a location is captured. + final ValueChanged? onLocationCapture; + + Future _getLocation(BuildContext context) async { + final Location location = Location(); + final LocationData locationData = await location.getLocation(); + + if (onLocationCapture != null) { + onLocationCapture?.call(locationData); + } + } + + @override + Widget build(BuildContext context) { + return ActionButton( + icon: ZetaIcons.pin, + onPressed: onLocationCapture != null ? () => _getLocation(context) : null, + semanticLabel: 'send your location', + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ObjectFlagProperty>.has('onLocationCapture', onLocationCapture)); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/actions/send_button.dart b/packages/zeta_flutter/lib/src/components/message_input/actions/send_button.dart new file mode 100644 index 000000000..ba3201f03 --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/actions/send_button.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../zeta_flutter.dart'; +import 'action_button.dart'; + +/// The send button used in the message input +class SendButton extends ZetaStatelessWidget { + /// Creates a [SendButton]. + const SendButton({ + super.key, + required this.controller, + this.attachments, + this.onPressed, + this.onLongPress, + this.disabled, + }); + + /// The text editing controller of the connected text input. + final TextEditingController controller; + + /// A list of attachment. + final List? attachments; + + /// The callback invoked on press of the send button. + final void Function(String text, List attachments)? onPressed; + + /// The callback invoked on long press of the send button. + final void Function(String text, List attachments)? onLongPress; + + /// Wether or not the button is disabled. + final bool? disabled; + + @override + Widget build(BuildContext context) { + final ZetaColors colors = Zeta.of(context).colors; + final ZetaSpacing spacing = Zeta.of(context).spacing; + + return Stack( + children: [ + ActionButton( + icon: ZetaIcons.send, + onPressed: onPressed != null ? () => onPressed!(controller.text, attachments ?? []) : null, + onLongPress: onLongPress != null ? () => onLongPress!(controller.text, attachments ?? []) : null, + disabled: disabled, + semanticLabel: 'send the message', + ), + if (attachments != null && attachments!.isNotEmpty) + Positioned( + right: 4, + top: 4, + child: Container( + width: spacing.large, + height: spacing.large, + decoration: BoxDecoration( + color: colors.surfacePrimarySubtle, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + attachments!.length.toString(), + style: TextStyle( + color: colors.mainDefault, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + ], + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('controller', controller)) + ..add(IterableProperty('attachments', attachments)) + ..add(ObjectFlagProperty attachments)?>.has('onPressed', onPressed)) + ..add(ObjectFlagProperty attachments)?>.has('onLongPress', onLongPress)) + ..add(DiagnosticsProperty('isDisabled', disabled)); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/actions/video_button.dart b/packages/zeta_flutter/lib/src/components/message_input/actions/video_button.dart new file mode 100644 index 000000000..56e15d486 --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/actions/video_button.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../../zeta_flutter.dart'; +import 'action_button.dart'; + +/// Video button for capturing videos +class VideoButton extends ZetaStatelessWidget { + /// Creates a [VideoButton]. + const VideoButton({ + super.key, + this.onCapture, + }); + + /// Callback for when a video is captured. + final ValueChanged? onCapture; + + Future _pickVideo(BuildContext context) async { + final ImagePicker picker = ImagePicker(); + final XFile? video = await picker.pickVideo(source: ImageSource.camera, maxDuration: const Duration(minutes: 5)); + if (video != null && onCapture != null) { + onCapture!(File(video.path)); + } + } + + @override + Widget build(BuildContext context) { + return ActionButton( + icon: ZetaIcons.video, + onPressed: onCapture != null ? () => _pickVideo(context) : null, + semanticLabel: 'capture a video', + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ObjectFlagProperty>.has('onCapture', onCapture)); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/actions/voice_button.dart b/packages/zeta_flutter/lib/src/components/message_input/actions/voice_button.dart new file mode 100644 index 000000000..d77895eb6 --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/actions/voice_button.dart @@ -0,0 +1,132 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:speech_to_text/speech_to_text.dart' as stt; + +import '../../../../zeta_flutter.dart'; +import 'action_button.dart'; + +/// A widget that provides voice input functionality. +class VoiceButton extends ZetaStatefulWidget { + /// Creates a [VoiceButton]. + const VoiceButton({ + super.key, + required this.controller, + this.disabled, + }); + + /// The text input editing controller for the voice input. + final TextEditingController controller; + + /// Whether or not the button is disabled. + final bool? disabled; + + @override + VoiceButtonState createState() => VoiceButtonState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('controller', controller)) + ..add(DiagnosticsProperty('disabled', disabled)); + } +} + +/// The state for the [VoiceButton]. +class VoiceButtonState extends State { + late stt.SpeechToText _speech; + bool _isListening = false; + String _recognizedText = ''; + int _cursorPos = 0; + + @override + void initState() { + super.initState(); + _speech = stt.SpeechToText(); + } + + /// Handles the speech recognition result. + void onSpeechResult(String text) { + if (text.isEmpty) return; + final beforeText = widget.controller.text.substring(0, _cursorPos); + final afterText = widget.controller.text.substring(_cursorPos); + final addOneToCursorPos = beforeText.isNotEmpty || afterText.isNotEmpty; + + if (_cursorPos != -1) { + widget.controller.text = + '${beforeText.isNotEmpty ? '${beforeText.trimRight()} ' : ''}$text${afterText.isNotEmpty ? ' ${afterText.trimLeft()}' : ''}'; + } else { + widget.controller.text = widget.controller.text + text; + } + + widget.controller.selection = TextSelection.fromPosition( + TextPosition( + offset: _cursorPos + text.length + (addOneToCursorPos ? 1 : 0), + ), + ); + } + + Future _startListening() async { + setState(() => _cursorPos = widget.controller.selection.baseOffset); + + final bool available = await _speech.initialize( + onStatus: (status) => { + if (status == stt.SpeechToText.notListeningStatus) + { + setState(() => _isListening = false), + }, + if (status == stt.SpeechToText.doneStatus) {onSpeechResult(_recognizedText)}, + }, + onError: (error) => { + setState(() => _isListening = false), + }, + ); + + if (available) { + setState(() => _isListening = true); + setState(() => _recognizedText = ''); + await _speech.listen( + pauseFor: const Duration(seconds: 3), + onResult: (result) { + setState(() { + _recognizedText = result.recognizedWords; + }); + }, + ); + } else { + _displayErrorToast(); + } + } + + Future _stopListening() async { + setState(() => _isListening = false); + await _speech.stop(); + } + + void _displayErrorToast() { + ScaffoldMessenger.of(context).showSnackBar( + ZetaSnackBar( + type: ZetaSnackBarType.error, + context: context, + content: const Text( + 'Speech recognition not available; could be due to insufficient permissions', + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final ZetaColors colors = Zeta.of(context).colors; + final ZetaSpacing spacing = Zeta.of(context).spacing; + + return ActionButton( + icon: ZetaIcons.microphone, + onPressed: _isListening ? _stopListening : _startListening, + color: _isListening ? colors.mainPrimary : null, + disabled: widget.disabled, + semanticLabel: 'voice input', + size: spacing.xl_2, + ); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/actions/voice_memo_button.dart b/packages/zeta_flutter/lib/src/components/message_input/actions/voice_memo_button.dart new file mode 100644 index 000000000..63057eb07 --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/actions/voice_memo_button.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../zeta_flutter.dart'; +import 'action_button.dart'; + +/// Voice memo button for recording voice notes +class VoiceMemoButton extends ZetaStatelessWidget { + /// Creates a [VoiceMemoButton]. + const VoiceMemoButton({ + super.key, + this.onSend, + this.maxDuration, + }); + + /// Callback for when a voice note is recorded. + final void Function(File file, Uint8List bytes)? onSend; + + /// The maximum duration of voice memos. + final Duration? maxDuration; + + Future _onClick(BuildContext context) async { + // Convert Uint8List to File for the callback + Future handleVoiceMemo(Uint8List audioData) async { + // Create a temporary file from the audio data + final directory = Directory.systemTemp; + final tempFile = File('${directory.path}/voice_memo_${DateTime.now().millisecondsSinceEpoch}.wav'); + await tempFile.writeAsBytes(audioData); + onSend!(tempFile, audioData); + } + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.3, + builder: (context, scrollController) { + return DecoratedBox( + decoration: BoxDecoration( + color: Zeta.of(context).colors.mainInverse, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + Expanded( + child: ZetaVoiceMemo( + maxRecordingDuration: maxDuration != null ? maxDuration! : const Duration(seconds: 120), + onSend: onSend != null + ? (audioData) { + unawaited(handleVoiceMemo(audioData)); + Navigator.of(context).pop(); + } + : null, + onDiscard: () { + Navigator.of(context).pop(); + }, + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return ActionButton( + icon: ZetaIcons.audio, + onPressed: onSend != null ? () => _onClick(context) : null, + semanticLabel: 'record a voice memo', + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ObjectFlagProperty.has('onSend', onSend)); + properties.add(DiagnosticsProperty('maxDuration', maxDuration)); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/components/document_attachment.dart b/packages/zeta_flutter/lib/src/components/message_input/components/document_attachment.dart new file mode 100644 index 000000000..be419ce7b --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/components/document_attachment.dart @@ -0,0 +1,59 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../zeta_flutter.dart'; +import '../utils/file_type_checker.dart'; + +/// Attachment item for displaying document files. +class DocumentAttachment extends ZetaStatelessWidget { + /// Creates a [DocumentAttachment] widget. + const DocumentAttachment({ + super.key, + required this.file, + }); + + /// The file to be displayed. + final File file; + + @override + Widget build(BuildContext context) { + final ZetaColors colors = Zeta.of(context).colors; + final ZetaSpacing spacing = Zeta.of(context).spacing; + + return Container( + decoration: BoxDecoration( + color: colors.mainLight, + border: Border.all(color: colors.borderDefault), + borderRadius: BorderRadius.circular(spacing.medium), + ), + padding: EdgeInsets.only(right: spacing.xl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: spacing.small), + child: Text(file.path.split('/').last, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + Row( + children: [ + FileTypeChecker.getFileIcon(context, file), + SizedBox(width: spacing.minimum), + Flexible( + child: Text(file.path.split('.').last.toUpperCase(), maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + ), + ], + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('file', file)); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/components/file_attachment.dart b/packages/zeta_flutter/lib/src/components/message_input/components/file_attachment.dart new file mode 100644 index 000000000..021c0d2db --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/components/file_attachment.dart @@ -0,0 +1,148 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../zeta_flutter.dart'; +import '../utils/file_type_checker.dart'; +import 'document_attachment.dart'; +import 'image_attachment.dart'; +import 'video_attachment.dart'; + +/// A widget that displays a file attachment in the Attachments Bar +class FileAttachment extends ZetaStatelessWidget { + /// Creates a [FileAttachment] widget. + const FileAttachment({ + super.key, + required this.file, + required this.index, + required this.onClose, + }); + + /// The file being displayed. + final File file; + + /// The index of the file in the list. + final int index; + + /// Callback function to be called when the attachment is closed. + final ValueChanged onClose; + + @override + Widget build(BuildContext context) { + final ZetaColors colors = Zeta.of(context).colors; + final ZetaSpacing spacing = Zeta.of(context).spacing; + + final size = spacing.xl_8 + spacing.medium; + + final isImage = FileTypeChecker.isImage(file); + final isVideo = FileTypeChecker.isVideo(file); + + Widget dialogImage = const SizedBox(); + if (isImage) dialogImage = Image.file(file); + if (isVideo) { + dialogImage = FutureBuilder( + future: FileTypeChecker.getVideoThumbnail(file), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { + return Stack( + children: [ + Image( + image: snapshot.data!, + ), + Positioned.fill( + child: Center( + child: Container( + decoration: const BoxDecoration( + color: Color.fromARGB(128, 0, 0, 0), + shape: BoxShape.circle, + ), + padding: EdgeInsets.all(spacing.medium), + child: Icon( + ZetaIcons.video, + size: spacing.xl_11, + color: Colors.white, + ), + ), + ), + ), + ], + ); + } else if (snapshot.connectionState == ConnectionState.done && !snapshot.hasData) { + return SizedBox( + width: size, + height: size, + child: const Center(child: Icon(ZetaIcons.video)), + ); + } else { + return SizedBox( + width: size, + height: size, + child: const Center(child: CircularProgressIndicator()), + ); + } + }, + ); + } + + return InkWell( + onTap: () async { + if (isImage || isVideo) { + await showDialog( + context: context, + builder: (_) => Dialog( + child: dialogImage, + ), + ); + } + }, + child: Container( + width: !isVideo && !isImage ? size * 3 : size, + height: size, + decoration: BoxDecoration( + color: colors.mainLight, + borderRadius: BorderRadius.circular(spacing.medium), + ), + child: Stack( + children: [ + if (isVideo) VideoAttachment(file: file), + if (isImage) ImageAttachment(file: file), + if (!isVideo && !isImage) DocumentAttachment(file: file), + Positioned( + top: 1, + right: 1, + child: GestureDetector( + onTap: () async => { + onClose(index), + await SystemChannels.textInput.invokeMethod('TextInput.show'), + }, + child: Container( + decoration: BoxDecoration( + color: colors.borderDefault, + shape: BoxShape.circle, + ), + padding: EdgeInsets.all(spacing.minimum), + child: Icon( + Icons.close, + size: spacing.large, + color: colors.mainDefault, + ), + ), + ), + ), + ], + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('file', file)) + ..add(IntProperty('index', index)) + ..add(ObjectFlagProperty>.has('onClose', onClose)); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/components/image_attachment.dart b/packages/zeta_flutter/lib/src/components/message_input/components/image_attachment.dart new file mode 100644 index 000000000..4fa3eec18 --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/components/image_attachment.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../zeta_flutter.dart'; + +/// Attachment item for displaying image files. +class ImageAttachment extends ZetaStatelessWidget { + /// Creates an [ImageAttachment] widget. + const ImageAttachment({super.key, required this.file}); + + /// The file to be displayed. + final File file; + + @override + Widget build(BuildContext context) { + final ZetaSpacing spacing = Zeta.of(context).spacing; + final size = spacing.xl_8 + spacing.medium; + + return ClipRRect( + borderRadius: BorderRadius.circular(spacing.medium), + child: Image( + image: FileImage(file), + width: size, + height: size, + fit: BoxFit.cover, + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('file', file)); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/components/video_attachment.dart b/packages/zeta_flutter/lib/src/components/message_input/components/video_attachment.dart new file mode 100644 index 000000000..a8da74d72 --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/components/video_attachment.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../zeta_flutter.dart'; +import '../utils/file_type_checker.dart'; + +/// Attachment item for displaying video files. +class VideoAttachment extends ZetaStatelessWidget { + /// Creates a [VideoAttachment] widget. + const VideoAttachment({ + super.key, + required this.file, + }); + + /// The file to be displayed. + final File file; + + @override + Widget build(BuildContext context) { + final ZetaSpacing spacing = Zeta.of(context).spacing; + final size = spacing.xl_8 + spacing.medium; + + return FutureBuilder?>( + future: FileTypeChecker.getVideoThumbnail(file), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image( + image: snapshot.data!, + width: size, + height: size, + fit: BoxFit.cover, + ), + ); + } else if (snapshot.connectionState == ConnectionState.done && !snapshot.hasData) { + return SizedBox( + width: size, + height: size, + child: const Center(child: Icon(ZetaIcons.video)), + ); + } else { + return SizedBox( + width: size, + height: size, + child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + } + }, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('file', file)); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/message_input.dart b/packages/zeta_flutter/lib/src/components/message_input/message_input.dart new file mode 100644 index 000000000..913d9bfde --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/message_input.dart @@ -0,0 +1,409 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:location/location.dart'; + +import '../../../zeta_flutter.dart'; +import 'actions/action_button.dart'; +import 'actions/attachment_button.dart'; +import 'actions/image_button.dart'; +import 'actions/location_button.dart'; +import 'actions/send_button.dart'; +import 'actions/video_button.dart'; +import 'actions/voice_button.dart'; +import 'actions/voice_memo_button.dart'; +import 'panels/actions_panel.dart'; +import 'panels/attachments_panel.dart'; + +/// A customizable message input widget for user text entry and sending actions. +/// +/// Figma: https://www.figma.com/design/JesXQFLaPJLc1BdBM4sisI/%F0%9F%A6%93-ZDS-Zeta---Components?node-id=18271-19682&m=dev +/// +/// Widgetbook: +class ZetaMessageInput extends ZetaStatefulWidget { + /// Default message input + const ZetaMessageInput({ + super.key, + this.controller, + this.onSend, + this.onLongPressSend, + this.onSendAttachments, + this.onSendVoiceMemo, + this.onSendLocation, + this.placeholder, + this.allowsVoiceInput, + this.cameraTrailingButton, + this.trailingButton, + this.attachmentAction = true, + this.pictureAction = true, + this.videoAction = true, + this.voiceMemoAction = true, + this.locationAction = true, + this.minLines, + this.maxLines, + this.hasActionMenu = false, + this.actions, + this.attachments, + this.allowedExtensions, + this.multiAttach = true, + this.disabled, + this.maxAttachmentSize = 1000000, + this.maxVoiceMemoDuration = const Duration(minutes: 1), + }); + + /// Comment input + const ZetaMessageInput.comment({ + super.key, + this.controller, + this.onSend, + this.onLongPressSend, + this.onSendAttachments, + this.onSendVoiceMemo, + this.onSendLocation, + this.placeholder = 'Add a comment', + this.allowsVoiceInput, + this.cameraTrailingButton, + this.trailingButton, + this.attachmentAction = false, + this.pictureAction = false, + this.videoAction = false, + this.voiceMemoAction = false, + this.locationAction = false, + this.minLines = 1, + this.maxLines = 1, + this.hasActionMenu = false, + this.actions, + this.attachments, + this.allowedExtensions, + this.multiAttach = false, + this.disabled, + this.maxAttachmentSize = 1000000, + this.maxVoiceMemoDuration = const Duration(minutes: 1), + }); + + /// Message input with predefined actions + const ZetaMessageInput.actionMenu({ + super.key, + this.controller, + this.onSend, + this.onLongPressSend, + this.onSendAttachments, + this.onSendVoiceMemo, + this.onSendLocation, + this.placeholder, + this.allowsVoiceInput, + this.cameraTrailingButton, + this.trailingButton, + this.attachmentAction = true, + this.pictureAction = true, + this.videoAction = true, + this.voiceMemoAction = true, + this.locationAction = true, + this.minLines = 1, + this.maxLines = 5, + this.hasActionMenu = true, + this.actions, + this.attachments, + this.allowedExtensions, + this.multiAttach = true, + this.disabled, + this.maxAttachmentSize = 1000000, + this.maxVoiceMemoDuration = const Duration(minutes: 1), + }); + + /// The text editing controller for the message input. + final TextEditingController? controller; + + /// When there is no text in the input the send button will be greyed out. + /// However, the onSend and onLongPressSend callbacks will still be triggered. + final ValueChanged? onSend; + + /// Callback for long press on the send button. + final ValueChanged? onLongPressSend; + + /// Callback for sending attachments (images, videos, files, etc.). + final ValueChanged>? onSendAttachments; + + /// Callback for sending voice memo. + final void Function(File file, Uint8List bytes)? onSendVoiceMemo; + + /// Callback for sending location. + final ValueChanged? onSendLocation; + + /// Placeholder text for the message input. + final String? placeholder; + + /// Whether the message input should support voice input. + final bool? allowsVoiceInput; + + /// Whether the camera button shows in place of a trailing button + /// Override trailingButton prop + final bool? cameraTrailingButton; + + /// The trailing button to show when the text field and attachments are empty; + final Widget? trailingButton; + + /// Whether to show the attachment button. + /// Defaults to true + final bool? attachmentAction; + + /// Whether to show the picture button. + /// Defaults to true + final bool? pictureAction; + + /// Whether to show the video button. + /// Defaults to true + final bool? videoAction; + + /// Whether to show the voice button. + /// Defaults to true + final bool? voiceMemoAction; + + /// Whether to show the location button. + /// Defaults to true + final bool? locationAction; + + /// {@macro flutter.widgets.editableText.minLines} + /// Minimum number of lines for the text input. + final int? minLines; + + /// {@macro flutter.widgets.editableText.maxLines} + /// Maximum number of lines for the text input. + final int? maxLines; + + /// Whether to have the actions menu + /// Defaults to true + /// If true, it will use all the default action props + final bool? hasActionMenu; + + /// The list of actions to display in the message input. + final List? actions; + + /// The list of file attachments. + final List? attachments; + + /// A list of allowed file extensions. + /// If null, it will accept any extension. + final List? allowedExtensions; + + /// Whether or not the attachment button should let the user attach multiple files at once. + final bool? multiAttach; + + /// Whether the entire message input is disabled. + /// Defaults to false. + final bool? disabled; + + /// The maximum size of attachments in bytes. + /// Defaults to 1,000,000 bytes || 1MB + final int? maxAttachmentSize; + + /// The maximum duration of voice memos. + final Duration? maxVoiceMemoDuration; + + @override + State createState() => _MessageInputState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('controller', controller)) + ..add(ObjectFlagProperty?>.has('onSend', onSend)) + ..add(ObjectFlagProperty?>.has('onLongPressSend', onLongPressSend)) + ..add(ObjectFlagProperty>?>.has('onSendAttachment', onSendAttachments)) + ..add(StringProperty('placeholder', placeholder)) + ..add(IntProperty('minLines', minLines)) + ..add(IntProperty('maxLines', maxLines)) + ..add(DiagnosticsProperty('voice', allowsVoiceInput)) + ..add(DiagnosticsProperty('allowsCameraInput', cameraTrailingButton)) + ..add(DiagnosticsProperty('usePresetActions', hasActionMenu)) + ..add(IterableProperty('attachments', attachments)) + ..add(DiagnosticsProperty('attachmentAction', attachmentAction)) + ..add(DiagnosticsProperty('cameraAction', pictureAction)) + ..add(DiagnosticsProperty('videoAction', videoAction)) + ..add(DiagnosticsProperty('voiceAction', voiceMemoAction)) + ..add(DiagnosticsProperty('locationAction', locationAction)) + ..add(IterableProperty('allowedExtensions', allowedExtensions)) + ..add(DiagnosticsProperty('multiAttach', multiAttach)) + ..add(ObjectFlagProperty.has('onSendVoiceMemo', onSendVoiceMemo)) + ..add(ObjectFlagProperty?>.has('onSendLocation', onSendLocation)) + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(IntProperty('maxAttachmentSize', maxAttachmentSize)) + ..add(DiagnosticsProperty('maxVoiceMemoDuration', maxVoiceMemoDuration)); + } +} + +class _MessageInputState extends State { + late TextEditingController _controller; + late bool _isMessageContentEmpty; + late bool _isActionMenuOpen; + final FocusNode _focusNode = FocusNode(); + late List _attachments; + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? TextEditingController(); + _isMessageContentEmpty = _controller.text.isEmpty; + _attachments = widget.attachments ?? []; + _isActionMenuOpen = false; + } + + @override + void dispose() { + if (widget.controller == null) { + _controller.dispose(); + } + _focusNode.dispose(); + super.dispose(); + } + + void _handleTextChange(String? text) { + if (text == null) return; + + final isTextEmpty = text.trim().isEmpty && _attachments.isEmpty; + if (_isMessageContentEmpty != isTextEmpty) setState(() => _isMessageContentEmpty = isTextEmpty); + } + + void _handleSend(String text, List attachments) { + widget.onSend!(text); + widget.onSendAttachments?.call(attachments); + } + + void _handleLongPressSend(String text, List attachments) { + widget.onLongPressSend!(text); + widget.onSendAttachments?.call(attachments); + } + + void _openActionsMenu() => setState(() => _isActionMenuOpen = !_isActionMenuOpen); + void _onCloseAttachment(int index) => setState(() => _attachments.removeAt(index)); + void _onCapture(File file) => setState(() => _attachments.add(file)); + void _onAttach(List files) => setState(() => _attachments.addAll(files)); + + @override + Widget build(BuildContext context) { + final ZetaColors colors = Zeta.of(context).colors; + final ZetaSpacing spacing = Zeta.of(context).spacing; + final verticalPadding = spacing.medium; + final horizontalPadding = spacing.large; + + final showCameraButton = _isMessageContentEmpty && (widget.cameraTrailingButton ?? false); + final showTrailingButton = + _isMessageContentEmpty && widget.trailingButton != null && !(widget.cameraTrailingButton ?? false); + final showSendButton = !_isMessageContentEmpty || (!showCameraButton && !showTrailingButton); + + return SafeArea( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () async { + /* This keeps the keyboard open when using the message input */ + /* TODO: glitch where if the user taps the ZetaTextInput border it closes the keyboard */ + await SystemChannels.textInput.invokeMethod('TextInput.show'); + }, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + child: Column( + children: [ + if (_attachments.isNotEmpty) + AttachmentsPanel( + attachments: _attachments, + onCloseAttachment: _onCloseAttachment, + ), + Container( + padding: EdgeInsets.symmetric( + vertical: verticalPadding, + horizontal: horizontalPadding, + ), + decoration: BoxDecoration( + color: colors.surfaceDefault, + border: Border( + top: BorderSide(color: colors.borderDefault), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + textBaseline: TextBaseline.ideographic, + children: [ + if (widget.hasActionMenu ?? false) + ActionButton( + onPressed: _openActionsMenu, + icon: ZetaIcons.add, + disabled: widget.disabled, + semanticLabel: 'open action menu', + ) + else + const Nothing(), + Expanded( + child: ZetaTextInput( + disabled: widget.disabled ?? false, + onChange: _handleTextChange, + focusNode: _focusNode, + minLines: widget.minLines, + maxLines: widget.maxLines, + controller: _controller, + rounded: context.rounded, + placeholder: widget.placeholder, + semanticLabel: widget.placeholder, + suffix: widget.allowsVoiceInput ?? false + ? VoiceButton( + controller: _controller, + disabled: widget.disabled, + ) + : null, + ), + ), + if (showCameraButton) + ImageButton( + onCapture: _onCapture, + disabled: widget.disabled, + ), + if (showTrailingButton) widget.trailingButton!, + if (showSendButton) + SendButton( + controller: _controller, + attachments: _attachments, + onPressed: widget.onSend != null ? _handleSend : null, + onLongPress: widget.onLongPressSend != null ? _handleLongPressSend : null, + disabled: (_isMessageContentEmpty || widget.onSend == null) || (widget.disabled ?? false), + ), + ].gap(spacing.small), + ), + ), + SizedBox( + height: (_isActionMenuOpen && (widget.hasActionMenu ?? false)) ? null : 0, + child: ActionsPanel( + actions: [ + if (widget.attachmentAction ?? false) + AttachmentButton( + allowMultiple: widget.multiAttach ?? false, + allowedExtensions: widget.allowedExtensions, + maxSize: widget.maxAttachmentSize, + onAttach: widget.onSendAttachments != null ? _onAttach : null, + ), + if (widget.pictureAction ?? false) + ImageButton( + onCapture: widget.onSendAttachments != null ? _onCapture : null, + ), + if (widget.videoAction ?? false) + VideoButton( + onCapture: widget.onSendAttachments != null ? _onCapture : null, + ), + if (widget.voiceMemoAction ?? false) + VoiceMemoButton(onSend: widget.onSendVoiceMemo, maxDuration: widget.maxVoiceMemoDuration), + if (widget.locationAction ?? false) + LocationButton( + onLocationCapture: widget.onSendLocation, + ), + if (widget.actions != null) ...widget.actions!, + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/panels/actions_panel.dart b/packages/zeta_flutter/lib/src/components/message_input/panels/actions_panel.dart new file mode 100644 index 000000000..98d200a3d --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/panels/actions_panel.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import '../../../../zeta_flutter.dart'; + +/// A widget that displays a horizontal action menu with given action buttons. +class ActionsPanel extends ZetaStatelessWidget { + /// Creates an [ActionsPanel] with the given [actions]. + const ActionsPanel({ + super.key, + required this.actions, + }); + + /// The list of action buttons to display in the menu. + final List actions; + + @override + Widget build(BuildContext context) { + final ZetaColors colors = Zeta.of(context).colors; + final ZetaSpacing spacing = Zeta.of(context).spacing; + + return ColoredBox( + color: colors.surfaceDefault, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: actions.map( + (action) { + return Padding( + padding: EdgeInsets.symmetric( + horizontal: spacing.small, + ), + child: action, + ); + }, + ).toList(), + ), + ), + ); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/panels/attachments_panel.dart b/packages/zeta_flutter/lib/src/components/message_input/panels/attachments_panel.dart new file mode 100644 index 000000000..8781b2394 --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/panels/attachments_panel.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../zeta_flutter.dart'; +import '../components/file_attachment.dart'; + +/// A bar that displays the list of attachments above the message input field. +class AttachmentsPanel extends ZetaStatefulWidget { + /// Creates an [AttachmentsPanel] widget. + const AttachmentsPanel({ + super.key, + required this.attachments, + required this.onCloseAttachment, + }); + + /// A list of files to display + final List attachments; + + /// Callback function to be called when an attachment is closed. + final ValueChanged onCloseAttachment; + + @override + State createState() => _AttachmentsPanelState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(IterableProperty('attachments', attachments)) + ..add(ObjectFlagProperty>.has('onCloseAttachment', onCloseAttachment)); + } +} + +class _AttachmentsPanelState extends State { + @override + Widget build(BuildContext context) { + final ZetaSpacing spacing = Zeta.of(context).spacing; + + return Container( + width: double.infinity, + color: Colors.transparent, + child: SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: spacing.large, + vertical: spacing.small, + ), + scrollDirection: Axis.horizontal, + child: Row( + textDirection: TextDirection.rtl, + children: List.generate( + widget.attachments.length, + (int i) => FileAttachment( + file: widget.attachments[i], + index: i, + onClose: widget.onCloseAttachment, + ), + ).gap(spacing.medium), + ), + ), + ); + } +} diff --git a/packages/zeta_flutter/lib/src/components/message_input/utils/file_type_checker.dart b/packages/zeta_flutter/lib/src/components/message_input/utils/file_type_checker.dart new file mode 100644 index 000000000..80e291321 --- /dev/null +++ b/packages/zeta_flutter/lib/src/components/message_input/utils/file_type_checker.dart @@ -0,0 +1,157 @@ +// not needed internal file +// ignore_for_file: public_member_api_docs + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:get_thumbnail_video/index.dart'; +import 'package:get_thumbnail_video/video_thumbnail.dart'; + +import '../../../../zeta_flutter.dart'; +import '../../../utils/file_attachment_icons.dart'; +// import 'package:mime/mime.dart'; + +/// File type enumeration +enum FileType { + image, + video, + document, + audio, + other, +} + +/// A utility class for checking file types. +class FileTypeChecker { + static const List _imageExtensions = [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'bmp', + 'webp', + 'tiff', + 'svg', + 'ico', + 'heic', + 'heif', + ]; + + static const List _videoExtensions = [ + 'mp4', + 'avi', + 'mov', + 'wmv', + 'flv', + 'webm', + 'mkv', + '3gp', + 'm4v', + 'f4v', + 'asf', + 'rm', + 'rmvb', + ]; + + static const List _documentExtensions = [ + 'pdf', + 'doc', + 'docx', + 'xls', + 'xlsx', + 'ppt', + 'pptx', + 'txt', + 'rtf', + 'odt', + 'ods', + 'odp', + 'csv', + ]; + + static const List _audioExtensions = [ + 'mp3', + 'wav', + 'flac', + 'aac', + 'ogg', + 'wma', + 'm4a', + 'aiff', + 'au', + ]; + + /// Check file type using extension + static FileType getFileTypeByExtension(File file) { + final extension = file.path.split('.').last.toLowerCase(); + + if (_imageExtensions.contains(extension)) return FileType.image; + if (_videoExtensions.contains(extension)) return FileType.video; + if (_documentExtensions.contains(extension)) return FileType.document; + if (_audioExtensions.contains(extension)) return FileType.audio; + + return FileType.other; + } + + /// Check file type using MIME type + // static FileType getFileTypeByMime(File file) { + // final mimeType = lookupMimeType(file.path); + + // if (mimeType == null) return FileType.other; + + // if (mimeType.startsWith('image/')) return FileType.image; + // if (mimeType.startsWith('video/')) return FileType.video; + // if (mimeType.startsWith('audio/')) return FileType.audio; + // if (mimeType.startsWith('application/') || mimeType.startsWith('text/')) { + // return FileType.document; + // } + + // return FileType.other; + // } + + /// Convenience methods + /// returns true if the file has an image extension (.png, .jpg, etc.) + static bool isImage(File file) => getFileTypeByExtension(file) == FileType.image; + + /// returns true if the file has an video extension (.mp4, .avi, etc.) + static bool isVideo(File file) => getFileTypeByExtension(file) == FileType.video; + + /// returns true if the file has a document extension (.pdf, .docx, etc.) + static bool isDocument(File file) => getFileTypeByExtension(file) == FileType.document; + + /// returns true if the file has an audio extension (.mp3, .wav, etc.) + static bool isAudio(File file) => getFileTypeByExtension(file) == FileType.audio; + + /// Generate a thumbnail from a video file + static Future getVideoThumbnail(File videoFile) async { + try { + final uint8list = await VideoThumbnail.thumbnailData( + video: videoFile.path, + imageFormat: ImageFormat.JPEG, + maxWidth: 780, + quality: 75, + ); + + return MemoryImage(uint8list); + } catch (e) { + // Return a default video icon if thumbnail generation fails + // throw Exception('Failed to generate thumbnail: $e'); + return null; + } + } + + /// Get the appropriate icon for a file based on its extension + static Widget getFileIcon(BuildContext context, File file) { + final spacing = Zeta.of(context).spacing; + + final extension = file.path.split('.').last.toLowerCase(); + + final icon = getFileExtensionIcon(extension); + + if (icon != null) { + return icon.paddingStart(spacing.small); + } + + // Default file icon + return const ZetaIcon(ZetaIcons.attachment); + } +} diff --git a/packages/zeta_flutter/lib/src/components/text_input/internal_text_input.dart b/packages/zeta_flutter/lib/src/components/text_input/internal_text_input.dart index b612dd4bc..4cb8b8b29 100644 --- a/packages/zeta_flutter/lib/src/components/text_input/internal_text_input.dart +++ b/packages/zeta_flutter/lib/src/components/text_input/internal_text_input.dart @@ -39,6 +39,8 @@ class InternalTextInput extends ZetaStatefulWidget { this.borderRadius, this.textInputAction, this.constrained = false, + this.minLines, + this.maxLines, }) : requirementLevel = requirementLevel ?? ZetaFormFieldRequirement.none, assert(prefix == null || prefixText == null, 'Only one of prefix or prefixText can be accepted.'), assert(suffix == null || suffixText == null, 'Only one of suffix or suffixText can be accepted.'); @@ -139,6 +141,14 @@ class InternalTextInput extends ZetaStatefulWidget { /// Determines if the prefix and suffix should be constrained. final bool constrained; + /// The minimum number of lines the input can display. + /// Defaults to 1. + final int? minLines; + + /// The maximum number of lines the input can display. + /// Defaults to 1. + final int? maxLines; + @override State createState() => InternalTextInputState(); @override @@ -167,7 +177,9 @@ class InternalTextInput extends ZetaStatefulWidget { ..add(DiagnosticsProperty('borderRadius', borderRadius)) ..add(StringProperty('semanticLabel', semanticLabel)) ..add(EnumProperty('textInputAction', textInputAction)) - ..add(DiagnosticsProperty('constrained', constrained)); + ..add(DiagnosticsProperty('constrained', constrained)) + ..add(IntProperty('minLines', minLines)) + ..add(IntProperty('maxLines', maxLines)); } } @@ -353,6 +365,8 @@ class InternalTextInputState extends State { cursorErrorColor: _colors.mainNegative, obscureText: widget.obscureText, focusNode: widget.focusNode, + maxLines: widget.maxLines ?? 1, + minLines: widget.minLines, decoration: InputDecoration( isDense: true, contentPadding: _contentPadding, diff --git a/packages/zeta_flutter/lib/src/components/text_input/text_input.dart b/packages/zeta_flutter/lib/src/components/text_input/text_input.dart index 202147382..9923d6368 100644 --- a/packages/zeta_flutter/lib/src/components/text_input/text_input.dart +++ b/packages/zeta_flutter/lib/src/components/text_input/text_input.dart @@ -48,6 +48,8 @@ class ZetaTextInput extends ZetaTextFormField { this.keyboardType, this.focusNode, this.semanticLabel, + this.minLines, + this.maxLines, }) : assert(initialValue == null || controller == null, 'Only one of initial value and controller can be accepted.'), assert(prefix == null || prefixText == null, 'Only one of prefix or prefixText can be accepted.'), assert(suffix == null || suffixText == null, 'Only one of suffix or suffixText can be accepted.'), @@ -77,6 +79,8 @@ class ZetaTextInput extends ZetaTextFormField { keyboardType: keyboardType, focusNode: focusNode, semanticLabel: semanticLabel, + minLines: minLines, + maxLines: maxLines, ); }, ); @@ -150,6 +154,14 @@ class ZetaTextInput extends ZetaTextFormField { /// {@macro zeta-widget-semantic-label} final String? semanticLabel; + /// The minimum number of lines the input can display. + /// Defaults to 1. + final int? minLines; + + /// The maximum number of lines the input can display. + /// Defaults to 1. + final int? maxLines; + @override FormFieldState createState() => ZetaTextFormFieldState(); @@ -176,6 +188,8 @@ class ZetaTextInput extends ZetaTextFormField { ..add(DiagnosticsProperty('obscureText', obscureText)) ..add(DiagnosticsProperty('keyboardType', keyboardType)) ..add(DiagnosticsProperty('focusNode', focusNode)) - ..add(StringProperty('semanticLabel', semanticLabel)); + ..add(StringProperty('semanticLabel', semanticLabel)) + ..add(IntProperty('minLines', minLines)) + ..add(IntProperty('maxLines', maxLines)); } } diff --git a/packages/zeta_flutter/lib/src/components/voice_memo/state/playback_state.dart b/packages/zeta_flutter/lib/src/components/voice_memo/state/playback_state.dart index c593d75f1..dad135992 100644 --- a/packages/zeta_flutter/lib/src/components/voice_memo/state/playback_state.dart +++ b/packages/zeta_flutter/lib/src/components/voice_memo/state/playback_state.dart @@ -22,14 +22,14 @@ enum FileFetchMode { /// State class for managing audio playback in the [ZetaVoiceMemo] and [ZetaAudioVisualizer]. class PlaybackState extends ChangeNotifier { /// Constructs a [PlaybackState]. - PlaybackState({String? assetPath, String? deviceFilePath, String? url}) { - unawaited(_init(assetPath, deviceFilePath, url)); + PlaybackState({String? assetPath, String? deviceFilePath, String? url, Uint8List? bytes}) { + unawaited(_init(assetPath, deviceFilePath, url, bytes)); _audioPlayer.onPlayerComplete.listen((_) => resetPlayback()); _positionSubscription = _audioPlayer.onPositionChanged.listen(_onPositionChanged); } - Future _init(String? assetPath, String? deviceFilePath, String? url) async { - await loadAudio(assetPath: assetPath, deviceFilePath: deviceFilePath, url: url); + Future _init(String? assetPath, String? deviceFilePath, String? url, Uint8List? bytes) async { + await loadAudio(assetPath: assetPath, deviceFilePath: deviceFilePath, url: url, bytes: bytes); await resetPlayback(); } @@ -98,6 +98,7 @@ class PlaybackState extends ChangeNotifier { String? assetPath, String? url, String? deviceFilePath, + Uint8List? bytes, List? audioChunks, RecordConfig? recordConfig, }) async { @@ -107,6 +108,8 @@ class PlaybackState extends ChangeNotifier { _localFile = await handleFile(url, FileFetchMode.url); } else if (deviceFilePath != null) { _localFile = Uri.file(deviceFilePath); + } else if (bytes != null) { + _audioChunks = bytes; } else if (audioChunks != null && audioChunks.isNotEmpty && recordConfig != null) { _audioChunks = Uint8List.fromList([ ...generatePCMWavHeader(audioChunks, recordConfig), diff --git a/packages/zeta_flutter/lib/src/components/voice_memo/ui/audio_visualizer.dart b/packages/zeta_flutter/lib/src/components/voice_memo/ui/audio_visualizer.dart index 574951619..335a4bff0 100644 --- a/packages/zeta_flutter/lib/src/components/voice_memo/ui/audio_visualizer.dart +++ b/packages/zeta_flutter/lib/src/components/voice_memo/ui/audio_visualizer.dart @@ -26,6 +26,7 @@ class ZetaAudioVisualizer extends ZetaStatefulWidget { this.onPause, this.onPlay, this.errorMessage = 'Audio cannot be played', + this.bytes, }); /// The path of a local audio asset to visualize. @@ -74,6 +75,9 @@ class ZetaAudioVisualizer extends ZetaStatefulWidget { /// Error message to display when audio can not be played. final String errorMessage; + /// A stream of audio bytes to visualize. + final Uint8List? bytes; + @override State createState() => _ZetaAudioVisualizerState(); @@ -91,7 +95,8 @@ class ZetaAudioVisualizer extends ZetaStatefulWidget { ..add(ColorProperty('foregroundColor', foregroundColor)) ..add(ColorProperty('tertiaryColor', tertiaryColor)) ..add(ColorProperty('playButtonColor', playButtonColor)) - ..add(StringProperty('errorMessage', errorMessage)); + ..add(StringProperty('errorMessage', errorMessage)) + ..add(ObjectFlagProperty('bytes', bytes)); } } @@ -229,6 +234,7 @@ class _ZetaAudioVisualizerState extends State { assetPath: widget.assetPath, deviceFilePath: widget.deviceFilePath, url: widget.url, + bytes: widget.bytes, ), child: Consumer( builder: (context, state, _) => _buildVisualizer(context, state), diff --git a/packages/zeta_flutter/lib/src/components/voice_memo/ui/voice_memo.dart b/packages/zeta_flutter/lib/src/components/voice_memo/ui/voice_memo.dart index 18ab26c34..078005c83 100644 --- a/packages/zeta_flutter/lib/src/components/voice_memo/ui/voice_memo.dart +++ b/packages/zeta_flutter/lib/src/components/voice_memo/ui/voice_memo.dart @@ -21,6 +21,7 @@ class ZetaVoiceMemo extends ZetaStatefulWidget { this.recordingLabel = 'Recording message...', this.maxLimitLabel = 'Recording message {timer} seconds left...', this.sendMessageLabel = 'Send message?', + this.sendActionIcon = ZetaIcons.send, this.playingLabel = 'Playing...', this.recordingNotAllowedLabel = 'Recording not allowed.', this.onDiscard, @@ -61,6 +62,11 @@ class ZetaVoiceMemo extends ZetaStatefulWidget { /// {@macro zeta-widget-translations} final String sendMessageLabel; + /// The icon to show on the trailing action + /// + /// {@macro zeta-widget-translations} + final IconData sendActionIcon; + /// The label shown when the voice memo is playing. /// /// Defaults to 'Playing...' @@ -128,7 +134,8 @@ class ZetaVoiceMemo extends ZetaStatefulWidget { ..add(StringProperty('recordingNotAllowedLabel', recordingNotAllowedLabel)) ..add(ObjectFlagProperty.has('onSend', onSend)) ..add(DiagnosticsProperty('recordConfig', recordConfig)) - ..add(DoubleProperty('loudnessMultiplier', loudnessMultiplier)); + ..add(DoubleProperty('loudnessMultiplier', loudnessMultiplier)) + ..add(DiagnosticsProperty('sendActionIcon', sendActionIcon)); } } @@ -239,7 +246,7 @@ class _ZetaVoiceMemoState extends State { mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( - icon: Icon(ZetaIcons.send, size: zeta.spacing.xl_6), + icon: Icon(widget.sendActionIcon, size: zeta.spacing.xl_6), padding: EdgeInsets.all(zeta.spacing.minimum), color: zeta.colors.mainPrimary, onPressed: (state.canRecord && diff --git a/packages/zeta_flutter/lib/src/utils/file_attachment_icons.dart b/packages/zeta_flutter/lib/src/utils/file_attachment_icons.dart new file mode 100644 index 000000000..c022d4ba8 --- /dev/null +++ b/packages/zeta_flutter/lib/src/utils/file_attachment_icons.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +import '../../zeta_flutter.dart'; + +/// Returns a ZetaIcon based on the file extension. +/// Returns null if no matching icon is found. +ZetaIcon? getFileExtensionIcon(String extension) { + final ext = extension.toLowerCase(); + final iconData = _extensionIconMap[ext]; + return iconData != null ? ZetaIcon(iconData) : null; +} + +const Map _extensionIconMap = { + 'aac': ZetaIcons.aac_attach, + 'ai': ZetaIcons.ai_attach, + 'asp': ZetaIcons.asp_attach, + '3ds': ZetaIcons.attach_3ds, + 'au': ZetaIcons.au_attach, + 'avi': ZetaIcons.avi_attach, + 'bin': ZetaIcons.bin_attach, + 'bmp': ZetaIcons.bmp_attach, + 'com': ZetaIcons.com_attach, + 'css': ZetaIcons.css_attach, + 'csv': ZetaIcons.csv_attach, + 'dat': ZetaIcons.dat_attach, + 'dbf': ZetaIcons.dbf_attach, + 'dll': ZetaIcons.dll_attach, + 'doc': ZetaIcons.doc_attach, + 'docx': ZetaIcons.docx_attach, + 'dwg': ZetaIcons.dwg_attach, + 'eml': ZetaIcons.eml_attach, + 'eps': ZetaIcons.eps_attach, + 'exe': ZetaIcons.exe_attach, + 'ext': ZetaIcons.ext_attach, + 'fla': ZetaIcons.fla_attach, + 'fodg': ZetaIcons.fodg_attach, + 'fodp': ZetaIcons.fodp_attach, + 'fods': ZetaIcons.fods_attach, + 'gif': ZetaIcons.gif_attach, + 'htm': ZetaIcons.htm_attach, + 'html': ZetaIcons.html_attach, + 'ico': ZetaIcons.ico_attach, + 'ini': ZetaIcons.ini_attach, + 'iso': ZetaIcons.iso_attach, + 'jar': ZetaIcons.jar_attach, + 'jpeg': ZetaIcons.jpeg_attach, + 'jpg': ZetaIcons.jpg_attach, + 'js': ZetaIcons.js_attach, + 'json': ZetaIcons.json_attach, + 'key': ZetaIcons.key_attach, + 'mdb': ZetaIcons.mdb_attach, + 'mkv': ZetaIcons.mkv_attach, + 'mov': ZetaIcons.mov_attach, + 'mp3': ZetaIcons.mp3_attach, + 'mp4': ZetaIcons.mp4_attach, + 'mpeg': ZetaIcons.mpeg_attach, + 'mpg': ZetaIcons.mpg_attach, + 'mpga': ZetaIcons.mpga_attach, + 'nfo': ZetaIcons.nfo_attach, + 'obj': ZetaIcons.obj_attach, + 'odf': ZetaIcons.odf_attach, + 'odp': ZetaIcons.odp_attach, + 'ods': ZetaIcons.ods_attach, + 'odt': ZetaIcons.odt_attach, + 'oog': ZetaIcons.oog_attach, + 'otf': ZetaIcons.otf_attach, + 'ott': ZetaIcons.ott_attach, + 'pdf': ZetaIcons.pdf_attach, + 'pkg': ZetaIcons.pkg_attach, + 'png': ZetaIcons.png_attach, + 'pps': ZetaIcons.pps_attach, + 'ppt': ZetaIcons.ppt_attach, + 'pptx': ZetaIcons.pptx_attach, + 'psd': ZetaIcons.psd_attach, + 'rar': ZetaIcons.rar_attach, + 'rtf': ZetaIcons.rtf_attach, + 'sdc': ZetaIcons.sdc_attach, + 'sdd': ZetaIcons.sdd_attach, + 'svg': ZetaIcons.svg_attach, + 'swf': ZetaIcons.swf_attach, + 'tif': ZetaIcons.tif_attach, + 'tiff': ZetaIcons.tiff_attach, + 'ttf': ZetaIcons.ttf_attach, + 'txt': ZetaIcons.txt_attach, + 'vcf': ZetaIcons.vcf_attach, + 'video': ZetaIcons.video_attach, + 'wav': ZetaIcons.wav_attach, + 'webm': ZetaIcons.webm_attach, + 'wmv': ZetaIcons.wmv_attach, + 'woff': ZetaIcons.woff_attach, + 'xlff': ZetaIcons.xlff_attach, + 'xliff': ZetaIcons.xliff_attach, + 'xls': ZetaIcons.xls_attach, + 'xlsb': ZetaIcons.xlsb_attach, + 'xlsm': ZetaIcons.xlsm_attach, + 'xlsx': ZetaIcons.xlsx_attach, + 'xml': ZetaIcons.xml_attach, + 'xsd': ZetaIcons.xsd_attach, + 'xslt': ZetaIcons.xslt_attach, + 'zip': ZetaIcons.zip_attach, +}; diff --git a/packages/zeta_flutter/lib/zeta_components.dart b/packages/zeta_flutter/lib/zeta_components.dart index 03d02bc93..ea8c80520 100644 --- a/packages/zeta_flutter/lib/zeta_components.dart +++ b/packages/zeta_flutter/lib/zeta_components.dart @@ -40,6 +40,7 @@ export 'src/components/in_page_banner/in_page_banner.dart'; export 'src/components/list_item/dropdown_list_item.dart'; export 'src/components/list_item/list_item.dart'; export 'src/components/list_item/notification_list_item.dart'; +export 'src/components/message_input/message_input.dart'; export 'src/components/navigation bar/navigation_bar.dart' hide NavigationItem; export 'src/components/navigation_rail/navigation_rail.dart'; export 'src/components/pagination/pagination.dart'; diff --git a/packages/zeta_flutter/pubspec.yaml b/packages/zeta_flutter/pubspec.yaml index 8fa258d11..2cce78571 100644 --- a/packages/zeta_flutter/pubspec.yaml +++ b/packages/zeta_flutter/pubspec.yaml @@ -14,14 +14,19 @@ resolution: workspace dependencies: audioplayers: ^6.5.1 collection: ^1.19.1 + file_picker: ^10.3.3 flutter: sdk: flutter flutter_slidable: ^4.0.1 + get_thumbnail_video: ^0.7.2 http: ^1.5.0 + image_picker: ^1.2.0 intl: ^0.20.2 + location: ^8.0.1 mask_text_input_formatter: ^2.9.0 provider: ^6.1.5+1 record: ^6.1.1 + speech_to_text: ^7.3.0 zeta_flutter_theme: ^1.2.2 zeta_flutter_utils: ^1.1.3 zeta_icons: ^1.1.5 diff --git a/packages/zeta_flutter/test/src/components/message_input/assets/zebra.png b/packages/zeta_flutter/test/src/components/message_input/assets/zebra.png new file mode 100644 index 000000000..fbc70e145 Binary files /dev/null and b/packages/zeta_flutter/test/src/components/message_input/assets/zebra.png differ diff --git a/packages/zeta_flutter/test/src/components/message_input/message_input_test.dart b/packages/zeta_flutter/test/src/components/message_input/message_input_test.dart new file mode 100644 index 000000000..95a690398 --- /dev/null +++ b/packages/zeta_flutter/test/src/components/message_input/message_input_test.dart @@ -0,0 +1,514 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zeta_flutter/src/components/message_input/actions/action_button.dart'; +import 'package:zeta_flutter/src/components/message_input/actions/image_button.dart'; +import 'package:zeta_flutter/src/components/message_input/actions/send_button.dart'; +import 'package:zeta_flutter/src/components/message_input/actions/voice_button.dart'; +import 'package:zeta_flutter/src/components/message_input/panels/attachments_panel.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../../test_utils/test_utils.dart'; + +void main() { + group('Accessibility Tests', () { + testWidgets('Input has correct semantics', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: ZetaMessageInput(placeholder: 'Type a message', onSend: (_) {})), + ), + ); + final textField = find.byType(TextField); + expect(textField, findsOneWidget); + final semantics = tester.getSemantics(textField); + // Check that the semantics label contains the placeholder + expect(semantics.label, contains('Type a message')); + }); + + testWidgets('Image button has correct semantics', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: ZetaMessageInput(cameraTrailingButton: true, onSend: (_) {})), + ), + ); + final imageButton = find.byType(ImageButton); + expect(imageButton, findsOneWidget); + final semantics = tester.getSemantics(imageButton); + expect(semantics.label, contains('capture an image')); + }); + + testWidgets('Action menu button has correct semantics', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: ZetaMessageInput.actionMenu(onSend: (_) {})), + ), + ); + final actionMenuButton = find.widgetWithIcon(ActionButton, ZetaIcons.add); + expect(actionMenuButton, findsOneWidget); + final semantics = tester.getSemantics(actionMenuButton); + expect(semantics.label, contains('open action menu')); + }); + + testWidgets('voice button has correct semantics', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ZetaMessageInput( + onSend: (_) {}, + allowsVoiceInput: true, + ), + ), + ), + ); + final voiceButton = find.byType(VoiceButton); + expect(voiceButton, findsOneWidget); + final semantics = tester.getSemantics(voiceButton); + expect(semantics.label, contains('voice input')); + }); + }); + + group('Content Tests', () { + testWidgets('Attachments bar opens when attachments are added', (WidgetTester tester) async { + final controller = TextEditingController(); + final attachments = [File('./assets/zebra.png')]; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ZetaMessageInput( + controller: controller, + attachments: attachments, + onSend: (_) {}, + ), + ), + ), + ); + // AttachmentsPanel should be present + expect(find.byType(AttachmentsPanel), findsOneWidget); + }); + + testWidgets('Attachments bar closes when all attachments are removed', (WidgetTester tester) async { + final controller = TextEditingController(); + final attachments = [File('./assets/zebra.png')]; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + return ZetaMessageInput( + controller: controller, + attachments: attachments, + onSend: (_) {}, + onSendAttachments: (files) { + setState(attachments.clear); + }, + ); + }, + ), + ), + ), + ); + // Initially, AttachmentsPanel should be present + expect(find.byType(AttachmentsPanel), findsOneWidget); + + // Click send button + final sendButton = find.byType(SendButton); + expect(sendButton, findsOneWidget); + tester.widget(sendButton).onPressed?.call('', []); + await tester.pump(); + + // AttachmentsPanel should not be present + expect(find.byType(AttachmentsPanel), findsNothing); + }); + + testWidgets('entire component can be disabled', (WidgetTester tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput( + controller: controller, + disabled: true, + allowsVoiceInput: true, + cameraTrailingButton: true, + hasActionMenu: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Input should be disabled + final textField = find.byType(TextField); + expect(textField, findsOneWidget); + expect(tester.widget(textField).enabled, isFalse); + + // Action menu button should be disabled + final actionMenuButton = find.widgetWithIcon(ActionButton, ZetaIcons.add); + expect(actionMenuButton, findsOneWidget); + final actionMenuIconButton = find.descendant( + of: actionMenuButton, + matching: find.byType(IconButton), + ); + expect(actionMenuIconButton, findsOneWidget); + expect(tester.widget(actionMenuIconButton).onPressed, isNull); + + // Main bar camera button should be disabled + final imageButton = find.byType(ImageButton); + expect(imageButton, findsOneWidget); + final imageButtonIconButton = find.descendant( + of: imageButton, + matching: find.byType(IconButton), + ); + expect(imageButtonIconButton, findsOneWidget); + expect(tester.widget(imageButtonIconButton).onPressed, isNull); + }); + + testWidgets('input accepts newline characters when maxLines > 1', (WidgetTester tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput( + controller: controller, + minLines: 1, + maxLines: 5, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final textField = find.byType(TextField); + expect(textField, findsOneWidget); + + // Enter multiline text + const multilineText = 'Line 1\nLine 2\nLine 3'; + await tester.enterText(textField, multilineText); + await tester.pumpAndSettle(); + expect(controller.text, multilineText); + }); + testWidgets('input does not accept newline characters when maxLines = 1', (WidgetTester tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput( + controller: controller, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final textField = find.byType(TextField); + expect(textField, findsOneWidget); + + // Enter multiline text + const multilineText = 'Line 1\nLine 2\nLine 3'; + const singleText = 'Line 1Line 2Line 3'; + await tester.enterText(textField, multilineText); + await tester.pumpAndSettle(); + expect(controller.text, singleText); + }); + + testWidgets('placeholder is displayed when input is empty', (WidgetTester tester) async { + final controller = TextEditingController(); + const placeholderText = 'Type your message...'; + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput( + controller: controller, + placeholder: placeholderText, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Find the placeholder text + expect(find.text(placeholderText), findsOneWidget); + }); + + testWidgets('controller and input are empty with initial state', (WidgetTester tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput( + controller: controller, + attachments: const [], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Controller is empty + expect(controller.text, ''); + + // Find the TextField and verify it's empty + final textField = find.byType(TextField); + expect(textField, findsOneWidget); + expect(tester.widget(textField).controller?.text ?? '', ''); + }); + + /* TODO: Implement maxLength for message input */ + // testWidgets('input accepts up to maxLength characters', (WidgetTester tester) async { + // final controller = TextEditingController(); + // await tester.pumpWidget(MaterialApp( + // home: Scaffold( + // body: ZetaMessageInput( + // controller: controller, + // maxLength: 10, + // ), + // ), + // )); + + // final inputFinder = find.byType(TextField); + // expect(inputFinder, findsOneWidget); + + // // Enter exactly maxLength characters + // await tester.enterText(inputFinder, '1234567890'); + // await tester.pump(); + // expect(controller.text, '1234567890'); + // expect(controller.text.length, 10); + // }); + + // testWidgets('additional characters beyond maxLength are ignored', (WidgetTester tester) async { + // final controller = TextEditingController(); + // await tester.pumpWidget(MaterialApp( + // home: Scaffold( + // body: ZetaMessageInput( + // controller: controller, + // maxLength: 10, + // ), + // ), + // )); + + // final inputFinder = find.byType(TextField); + // expect(inputFinder, findsOneWidget); + + // // Enter more than maxLength characters + // await tester.enterText(inputFinder, '123456789012345'); + // await tester.pump(); + // expect(controller.text, '1234567890'); + // expect(controller.text.length, 10); + // }); + + testWidgets('send button onPressed is null with initialState', (WidgetTester tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput( + controller: controller, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Find the send button and verify it's disabled + final sendButton = find.byType(SendButton); + expect(sendButton, findsOneWidget); + expect(tester.widget(sendButton).onPressed, isNull); + }); + }); + + group('Dimensions Tests', () {}); + + group('Styling Tests', () { + testWidgets('send button is colors.mainDisabled with initialState', (WidgetTester tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput( + controller: controller, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final context = getBuildContext(tester, ZetaMessageInput); + final colors = Zeta.of(context).colors; + + final sendButton = find.byType(SendButton); + expect(sendButton, findsOneWidget); + + // Check color is mainDisabled + final icon = tester.widget(find.descendant(of: sendButton, matching: find.byType(Icon))); + expect(icon.color, colors.mainDisabled); + }); + }); + + group('Interaction Tests', () { + testWidgets('clicking send with text invokes onSend callback with correct message', (WidgetTester tester) async { + final controller = TextEditingController(); + String? sentMessage; + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput( + controller: controller, + onSend: (msg) => sentMessage = msg, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final textField = find.byType(TextField); + await tester.enterText(textField, 'Hello world'); + await tester.pump(); + + final sendButton = find.byType(SendButton); + await tester.tap(sendButton); + await tester.pump(); + + expect(sentMessage, 'Hello world'); + }); + + testWidgets('send button becomes default color when input has valid non-whitespace text and onSend is provided', + (WidgetTester tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput( + controller: controller, + onSend: (value) {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final context = getBuildContext(tester, ZetaMessageInput); + final colors = Zeta.of(context).colors; + + final textField = find.byType(TextField); + expect(textField, findsOneWidget); + + await tester.enterText(textField, 'Hello'); + await tester.pump(); + + final sendButton = find.byType(SendButton); + expect(sendButton, findsOneWidget); + + // Check color is mainDefault + final icon = tester.widget(find.descendant(of: sendButton, matching: find.byType(Icon))); + expect(icon.color, colors.mainDefault); + }); + + testWidgets('send button remains as disabled color for whitespace-only input', (WidgetTester tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + final context = getBuildContext(tester, ZetaMessageInput); + final colors = Zeta.of(context).colors; + + final textField = find.byType(TextField); + expect(textField, findsOneWidget); + + await tester.enterText(textField, ' '); + await tester.pump(); + + final sendButton = find.byType(SendButton); + expect(sendButton, findsOneWidget); + + // Check color is mainDisabled + final icon = tester.widget(find.descendant(of: sendButton, matching: find.byType(Icon))); + expect(icon.color, colors.mainDisabled); + }); + testWidgets('typing a character updates the input value', (WidgetTester tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + + final textField = find.byType(TextField); + expect(textField, findsOneWidget); + + await tester.enterText(textField, 'A'); + await tester.pump(); + expect(controller.text, 'A'); + }); + + testWidgets('typing multiple characters updates accordingly', (WidgetTester tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + + final textField = find.byType(TextField); + expect(textField, findsOneWidget); + + await tester.enterText(textField, 'Hello, world!'); + await tester.pump(); + expect(controller.text, 'Hello, world!'); + }); + + testWidgets('pasting text into the field updates the input value', (WidgetTester tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + + final textField = find.byType(TextField); + expect(textField, findsOneWidget); + + // Simulate pasting by setting the controller text directly + controller.text = 'Pasted text'; + await tester.pump(); + expect(controller.text, 'Pasted text'); + }); + + testWidgets('input gains focus on tap/click', (WidgetTester tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + MaterialApp( + home: TestApp( + home: ZetaMessageInput(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + + final textField = find.byType(TextField); + expect(textField, findsOneWidget); + + // Tap the input field + await tester.tap(textField); + await tester.pumpAndSettle(); + + // The input should have focus + final textFieldWidget = tester.widget(textField); + expect(textFieldWidget.focusNode?.hasFocus ?? false, isTrue); + }); + }); + + group('Performance Tests', () {}); +} diff --git a/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_rounded_continuous.png b/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_rounded_continuous.png index f460d30ef..ca1b29eb5 100644 Binary files a/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_rounded_continuous.png and b/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_rounded_continuous.png differ diff --git a/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_rounded_disabled.png b/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_rounded_disabled.png index ed0589834..63709b763 100644 Binary files a/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_rounded_disabled.png and b/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_rounded_disabled.png differ diff --git a/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_rounded_stepped.png b/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_rounded_stepped.png index 601ee3a13..52d909e19 100644 Binary files a/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_rounded_stepped.png and b/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_rounded_stepped.png differ diff --git a/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_sharp_continuous.png b/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_sharp_continuous.png index 46728ce83..6ad1ea1df 100644 Binary files a/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_sharp_continuous.png and b/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_sharp_continuous.png differ diff --git a/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_sharp_disabled.png b/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_sharp_disabled.png index 2d3c5321b..cb9bd0aba 100644 Binary files a/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_sharp_disabled.png and b/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_sharp_disabled.png differ diff --git a/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_sharp_stepped.png b/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_sharp_stepped.png index b06ee8709..7fbc18590 100644 Binary files a/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_sharp_stepped.png and b/packages/zeta_flutter/test/src/components/range_selector/golden/range_selector_sharp_stepped.png differ diff --git a/packages/zeta_flutter/test/src/components/range_selector/golden/range_slider_sharp_stepped.png b/packages/zeta_flutter/test/src/components/range_selector/golden/range_slider_sharp_stepped.png index e89d4a047..40c223c98 100644 Binary files a/packages/zeta_flutter/test/src/components/range_selector/golden/range_slider_sharp_stepped.png and b/packages/zeta_flutter/test/src/components/range_selector/golden/range_slider_sharp_stepped.png differ diff --git a/pubspec.yaml b/pubspec.yaml index b8ef27241..1923527e9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,6 +6,7 @@ environment: dev_dependencies: melos: ^6.3.3 + analyzer: any workspace: - packages/zeta_flutter - packages/zeta_flutter_theme diff --git a/widgetbook/lib/main.directories.g.dart b/widgetbook/lib/main.directories.g.dart index 68d2b09f2..8587ff389 100644 --- a/widgetbook/lib/main.directories.g.dart +++ b/widgetbook/lib/main.directories.g.dart @@ -52,6 +52,8 @@ import 'package:zeta_widgetbook/src/components/in_page_banner.widgetbook.dart' as _zeta_widgetbook_src_components_in_page_banner_widgetbook; import 'package:zeta_widgetbook/src/components/list_item_widgetbook.dart' as _zeta_widgetbook_src_components_list_item_widgetbook; +import 'package:zeta_widgetbook/src/components/message_input.widgetbook.dart' + as _zeta_widgetbook_src_components_message_input_widgetbook; import 'package:zeta_widgetbook/src/components/navigation_bar.widgetbook.dart' as _zeta_widgetbook_src_components_navigation_bar_widgetbook; import 'package:zeta_widgetbook/src/components/navigation_rail.widgetbook.dart' @@ -635,6 +637,20 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'MessageInput', + children: [ + _widgetbook.WidgetbookLeafComponent( + name: 'MessageInput', + useCase: _widgetbook.WidgetbookUseCase( + name: 'Message Input', + builder: _zeta_widgetbook_src_components_message_input_widgetbook.defaultMessageInput, + designLink: + 'https://www.figma.com/design/JesXQFLaPJLc1BdBM4sisI/%F0%9F%A6%93-ZDS---Components?node-id=18271-19682', + ), + ) + ], + ), _widgetbook.WidgetbookFolder( name: 'Navigation Bar', children: [ diff --git a/widgetbook/lib/src/components/message_input.widgetbook.dart b/widgetbook/lib/src/components/message_input.widgetbook.dart new file mode 100644 index 000000000..6e2fca45c --- /dev/null +++ b/widgetbook/lib/src/components/message_input.widgetbook.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; +import 'package:zeta_flutter/zeta_flutter.dart'; +import 'package:zeta_widgetbook/main.dart'; +import 'package:zeta_widgetbook/src/utils/utils.dart'; + +@widgetbook.UseCase( + name: 'Message Input', + type: ZetaMessageInput, + path: '$componentsPath/ZetaMessageInput', + designLink: 'https://www.figma.com/design/JesXQFLaPJLc1BdBM4sisI/%F0%9F%A6%93-ZDS---Components?node-id=18271-19682', +) +Widget defaultMessageInput(BuildContext context) { + final controller = TextEditingController(); + + final String? placeholder = context.knobs.stringOrNull(label: 'Placeholder'); + final bool allowsVoiceInput = context.knobs.boolean(label: 'Allows Voice Input', initialValue: true); + final range = context.knobs.range(label: 'Range of lines', initialValue: const RangeValues(1, 10)); + final minLines = range.start.round(); + final maxLines = range.end.round(); + final bool disabled = context.knobs.boolean(label: 'Disabled', initialValue: false); + final bool hasActionMenu = context.knobs.boolean(label: 'Has Action Menu', initialValue: true); + final bool cameraTrailingButton = context.knobs.boolean(label: 'Camera Trailing Button', initialValue: false); + + return StatefulBuilder( + builder: (context, setState) => ZetaMessageInput.actionMenu( + controller: controller, + placeholder: placeholder, + allowsVoiceInput: allowsVoiceInput, + minLines: minLines, + maxLines: maxLines, + disabled: disabled, + hasActionMenu: hasActionMenu, + cameraTrailingButton: cameraTrailingButton, + attachments: [], + onSend: (message) {}, + onSendAttachments: (value) {}, + )); +} diff --git a/widgetbook/linux/flutter/generated_plugin_registrant.cc b/widgetbook/linux/flutter/generated_plugin_registrant.cc index 6867a3e36..7b54566d7 100644 --- a/widgetbook/linux/flutter/generated_plugin_registrant.cc +++ b/widgetbook/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include @@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); + 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) record_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); record_linux_plugin_register_with_registrar(record_linux_registrar); diff --git a/widgetbook/linux/flutter/generated_plugins.cmake b/widgetbook/linux/flutter/generated_plugins.cmake index 81097248a..daf934bf2 100644 --- a/widgetbook/linux/flutter/generated_plugins.cmake +++ b/widgetbook/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux + file_selector_linux record_linux url_launcher_linux ) diff --git a/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift b/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift index 50dda46b8..3d835ba2f 100644 --- a/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,15 +6,23 @@ import FlutterMacOS import Foundation import audioplayers_darwin +import file_picker +import file_selector_macos +import location import path_provider_foundation import record_macos import shared_preferences_foundation +import speech_to_text import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + LocationPlugin.register(with: registry.registrar(forPlugin: "LocationPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/widgetbook/macos/Podfile b/widgetbook/macos/Podfile index b52666a10..dbccf89c9 100644 --- a/widgetbook/macos/Podfile +++ b/widgetbook/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.15' +platform :osx, '11.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/widgetbook/macos/Podfile.lock b/widgetbook/macos/Podfile.lock deleted file mode 100644 index 15f5ba121..000000000 --- a/widgetbook/macos/Podfile.lock +++ /dev/null @@ -1,49 +0,0 @@ -PODS: - - audioplayers_darwin (0.0.1): - - Flutter - - FlutterMacOS - - FlutterMacOS (1.0.0) - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - record_macos (1.1.0): - - FlutterMacOS - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - url_launcher_macos (0.0.1): - - FlutterMacOS - -DEPENDENCIES: - - audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`) - - FlutterMacOS (from `Flutter/ephemeral`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`) - - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - -EXTERNAL SOURCES: - audioplayers_darwin: - :path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin - FlutterMacOS: - :path: Flutter/ephemeral - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin - record_macos: - :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos - shared_preferences_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin - url_launcher_macos: - :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos - -SPEC CHECKSUMS: - audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923 - FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - record_macos: 4440ca269ad3b870ebb1965297a365d558f0c520 - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 - url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 - -PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 - -COCOAPODS: 1.16.2 diff --git a/widgetbook/macos/Runner.xcodeproj/project.pbxproj b/widgetbook/macos/Runner.xcodeproj/project.pbxproj index 49ad312e1..adc940971 100644 --- a/widgetbook/macos/Runner.xcodeproj/project.pbxproj +++ b/widgetbook/macos/Runner.xcodeproj/project.pbxproj @@ -557,7 +557,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -639,7 +639,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -689,7 +689,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/widgetbook/windows/flutter/generated_plugin_registrant.cc b/widgetbook/windows/flutter/generated_plugin_registrant.cc index 67b645c2c..e7776798e 100644 --- a/widgetbook/windows/flutter/generated_plugin_registrant.cc +++ b/widgetbook/windows/flutter/generated_plugin_registrant.cc @@ -7,14 +7,20 @@ #include "generated_plugin_registrant.h" #include +#include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { AudioplayersWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); RecordWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); + SpeechToTextWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SpeechToTextWindows")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/widgetbook/windows/flutter/generated_plugins.cmake b/widgetbook/windows/flutter/generated_plugins.cmake index f071d09be..357973d31 100644 --- a/widgetbook/windows/flutter/generated_plugins.cmake +++ b/widgetbook/windows/flutter/generated_plugins.cmake @@ -4,7 +4,9 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows + file_selector_windows record_windows + speech_to_text_windows url_launcher_windows )