diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 81a21fd..f5124b4 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -30,16 +30,17 @@ class _MessagesListState extends State with SingleTickerProviderSt final TextEditingController _messageTextController = TextEditingController(); final ScrollController _sessionListScrollController = ScrollController(); final ScrollController _messageScrollController = ScrollController(); + final List _loadedFiles = []; bool _hasText = false; bool _isSending = false; - bool _showSessionListScrollChevron = false; + bool _attachmentPickerOpen = false; + bool _showBottomBarShadow = false; - File? _loadedFile; + bool _showSessionListScrollChevron = false; double get _shevronOpacity => _showSessionListScrollChevron ? 1.0 : 0.0; - @override void dispose() { _messageTextController.dispose(); @@ -65,6 +66,11 @@ class _MessagesListState extends State with SingleTickerProviderSt }); _messageScrollController.addListener(() { if (!_messageScrollController.hasClients) return; + if (_attachmentPickerOpen && _loadedFiles.isEmpty) { + setState(() { + _attachmentPickerOpen = false; + }); + } if (_messageScrollController.position.atEdge && _messageScrollController.position.pixels == 0 && _showBottomBarShadow) { setState(() { @@ -78,11 +84,9 @@ class _MessagesListState extends State with SingleTickerProviderSt }); } - Future sendTextMessage(ScaffoldMessengerState scaffoldMessenger, ApiClient client, MessagingClient mClient, String content) async { + Future sendTextMessage(ApiClient client, MessagingClient mClient, + String content) async { if (content.isEmpty) return; - setState(() { - _isSending = true; - }); final message = Message( id: Message.generateId(), recipientId: widget.friend.id, @@ -91,58 +95,26 @@ class _MessagesListState extends State with SingleTickerProviderSt content: content, sendTime: DateTime.now().toUtc(), ); - try { - mClient.sendMessage(message); - _messageTextController.clear(); - setState(() {}); - } catch (e) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text("Failed to send message\n$e", - maxLines: null, - ), - ), - ); - setState(() { - _isSending = false; - }); - } + mClient.sendMessage(message); + _messageTextController.clear(); } - Future sendImageMessage(ScaffoldMessengerState scaffoldMessenger, ApiClient client, MessagingClient mClient, File file, machineId) async { - setState(() { - _isSending = true; - }); - try { - final record = await RecordApi.uploadImage( - client, - image: file, - machineId: machineId, - ); - - final message = Message( - id: Message.generateId(), - recipientId: widget.friend.id, - senderId: client.userId, - type: MessageType.object, - content: jsonEncode(record.toMap()), - sendTime: DateTime.now().toUtc(), - ); - mClient.sendMessage(message); - _messageTextController.clear(); - _loadedFile = null; - } catch (e) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text("Failed to send file\n$e", - maxLines: null, - ), - ), - ); - } - setState(() { - _isSending = false; - }); + Future sendImageMessage(ApiClient client, MessagingClient mClient, File file, machineId) async { + final record = await RecordApi.uploadImage( + client, + image: file, + machineId: machineId, + ); + final message = Message( + id: Message.generateId(), + recipientId: widget.friend.id, + senderId: client.userId, + type: MessageType.object, + content: jsonEncode(record.toMap()), + sendTime: DateTime.now().toUtc(), + ); + mClient.sendMessage(message); + _messageTextController.clear(); } @override @@ -223,72 +195,154 @@ class _MessagesListState extends State with SingleTickerProviderSt ), ), Expanded( - child: Builder( - builder: (context) { - if (cache == null) { - return const Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - LinearProgressIndicator() - ], - ); - } - if (cache.error != null) { - return DefaultErrorWidget( - message: cache.error.toString(), - onRetry: () { - setState(() { - mClient.deleteUserMessageCache(widget.friend.id); - }); - mClient.loadUserMessageCache(widget.friend.id); - }, - ); - } - if (cache.messages.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.message_outlined), - Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Text( - "There are no messages here\nWhy not say hello?", - textAlign: TextAlign.center, - style: Theme - .of(context) - .textTheme - .titleMedium, - ), - ) - ], - ), - ); - } - return ListView.builder( - controller: _messageScrollController, - reverse: true, - itemCount: cache.messages.length, - itemBuilder: (context, index) { - final entry = cache.messages[index]; - if (index == cache.messages.length - 1) { - return Padding( - padding: const EdgeInsets.only(top: 12), - child: MessageBubble(message: entry,), + child: Stack( + children: [ + Builder( + builder: (context) { + if (cache == null) { + return const Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + LinearProgressIndicator() + ], ); } - return MessageBubble(message: entry,); + if (cache.error != null) { + return DefaultErrorWidget( + message: cache.error.toString(), + onRetry: () { + setState(() { + mClient.deleteUserMessageCache(widget.friend.id); + }); + mClient.loadUserMessageCache(widget.friend.id); + }, + ); + } + if (cache.messages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.message_outlined), + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + "There are no messages here\nWhy not say hello?", + textAlign: TextAlign.center, + style: Theme + .of(context) + .textTheme + .titleMedium, + ), + ) + ], + ), + ); + } + return ListView.builder( + controller: _messageScrollController, + reverse: true, + itemCount: cache.messages.length, + itemBuilder: (context, index) { + final entry = cache.messages[index]; + if (index == cache.messages.length - 1) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: MessageBubble(message: entry,), + ); + } + return MessageBubble(message: entry,); + }, + ); }, - ); - }, + ), + Align( + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + blurRadius: 8, + color: Theme + .of(context) + .shadowColor, + offset: const Offset(0, 4), + ), + ], + color: Theme + .of(context) + .colorScheme + .background, + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + transitionBuilder: (Widget child, animation) => SizeTransition(sizeFactor: animation, child: child,), + child: switch ((_attachmentPickerOpen, _loadedFiles)) { + (true, []) => Row( + key: const ValueKey("attachment-picker"), + children: [ + TextButton.icon( + onPressed: () async { + final result = await FilePicker.platform.pickFiles(type: FileType.image); + if (result != null && result.files.single.path != null) { + setState(() { + _loadedFiles.add(File(result.files.single.path!)); + }); + } + }, + icon: const Icon(Icons.image), + label: const Text("Gallery"), + ), + TextButton.icon(onPressed: (){}, icon: const Icon(Icons.camera), label: const Text("Camera"),), + ], + ), + (false, []) => null, + (_, _) => Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _loadedFiles.map((e) => TextButton.icon(onPressed: (){}, label: Text(basename(e.path)), icon: const Icon(Icons.attach_file))).toList() + ), + ), + ), + IconButton(onPressed: () async { + final result = await FilePicker.platform.pickFiles(type: FileType.image); + if (result != null && result.files.single.path != null) { + setState(() { + _loadedFiles.add(File(result.files.single.path!)); + }); + } + }, icon: const Icon(Icons.image)), + IconButton(onPressed: () {}, icon: const Icon(Icons.camera)), + ], + ) + }, + ), + ), + ], + ), + ), + if (_isSending && _loadedFiles.isNotEmpty) + const Align( + alignment: Alignment.bottomCenter, + child: LinearProgressIndicator(), + ), + ], ), ), - if (_isSending && _loadedFile != null) const LinearProgressIndicator(), AnimatedContainer( decoration: BoxDecoration( boxShadow: [ BoxShadow( - blurRadius: _showBottomBarShadow ? 8 : 0, + blurRadius: _showBottomBarShadow && !_attachmentPickerOpen ? 8 : 0, color: Theme .of(context) .shadowColor, @@ -304,24 +358,42 @@ class _MessagesListState extends State with SingleTickerProviderSt duration: const Duration(milliseconds: 250), child: Row( children: [ - IconButton( - onPressed: _hasText ? null : _loadedFile == null ? () async { - //final machineId = ClientHolder.of(context).settingsClient.currentSettings.machineId.valueOrDefault; - final result = await FilePicker.platform.pickFiles(type: FileType.image); - - if (result != null && result.files.single.path != null) { + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (Widget child, Animation animation) => + FadeTransition( + opacity: animation, + child: RotationTransition( + turns: Tween(begin: 0.6, end: 1).animate(animation), + child: child, + ), + ), + child: !_attachmentPickerOpen ? + IconButton( + key: const ValueKey("add-attachment-icon"), + onPressed: () async { setState(() { - _loadedFile = File(result.files.single.path!); + _attachmentPickerOpen = true; }); - } - } : () => setState(() => _loadedFile = null), - icon: _loadedFile == null ? const Icon(Icons.attach_file) : const Icon(Icons.close), + }, + icon: const Icon(Icons.attach_file), + ) : + IconButton( + key: const ValueKey("remove-attachment-icon"), + onPressed: () { + setState(() { + _loadedFiles.clear(); + _attachmentPickerOpen = false; + }); + }, + icon: const Icon(Icons.close), + ), ), Expanded( child: Padding( padding: const EdgeInsets.all(8), child: TextField( - enabled: cache != null && cache.error == null && _loadedFile == null, + enabled: cache != null && cache.error == null, autocorrect: true, controller: _messageTextController, maxLines: 4, @@ -339,8 +411,8 @@ class _MessagesListState extends State with SingleTickerProviderSt }, decoration: InputDecoration( isDense: true, - hintText: _loadedFile == null ? "Message ${widget.friend - .username}..." : "Send ${basename(_loadedFile?.path ?? "")}", + hintText: "Message ${widget.friend + .username}...", hintMaxLines: 1, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), border: OutlineInputBorder( @@ -352,17 +424,52 @@ class _MessagesListState extends State with SingleTickerProviderSt ), Padding( padding: const EdgeInsets.only(left: 8, right: 4.0), - child: IconButton( - splashRadius: 24, - onPressed: _isSending ? null : () async { - if (_loadedFile == null) { - await sendTextMessage(ScaffoldMessenger.of(context), apiClient, mClient, _messageTextController.text); - } else { - await sendImageMessage(ScaffoldMessenger.of(context), apiClient, mClient, _loadedFile!, ClientHolder.of(context).settingsClient.currentSettings.machineId.valueOrDefault); - } - }, - iconSize: 28, - icon: const Icon(Icons.send), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (Widget child, Animation animation) => + FadeTransition(opacity: animation, child: RotationTransition( + turns: Tween(begin: 0.5, end: 1).animate(animation), child: child,),), + child: _hasText || _loadedFiles.isNotEmpty ? IconButton( + key: const ValueKey("send-button"), + splashRadius: 24, + onPressed: _isSending ? null : () async { + final sMsgnr = ScaffoldMessenger.of(context); + setState(() { + _isSending = true; + }); + try { + for (final file in _loadedFiles) { + await sendImageMessage(apiClient, mClient, file, ClientHolder + .of(context) + .settingsClient + .currentSettings + .machineId + .valueOrDefault); + } + + if (_hasText) { + await sendTextMessage(apiClient, mClient, _messageTextController.text); + } + _messageTextController.clear(); + _loadedFiles.clear(); + _attachmentPickerOpen = false; + } catch (e, s) { + FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); + sMsgnr.showSnackBar(SnackBar(content: Text("Failed to send a message: $e"))); + } + setState(() { + _isSending = false; + }); + }, + iconSize: 28, + icon: const Icon(Icons.send), + ) : IconButton( + key: const ValueKey("mic-button"), + splashRadius: 24, + onPressed: _isSending ? null : () async {}, + iconSize: 28, + icon: const Icon(Icons.mic_outlined), + ), ), ), ], diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 075ecba..9750187 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 6fd458b..0238680 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color flutter_secure_storage_linux + record_linux url_launcher_linux ) diff --git a/pubspec.lock b/pubspec.lock index 1fadaed..251bc5c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -560,6 +560,54 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.5" + record: + dependency: "direct main" + description: + name: record + sha256: f703397f5a60d9b2b655b3acc94ba079b2d9a67dc0725bdb90ef2fee2441ebf7 + url: "https://pub.dev" + source: hosted + version: "4.4.4" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "348db92c4ec1b67b1b85d791381c8c99d7c6908de141e7c9edc20dad399b15ce" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: d1d0199d1395f05e218207e8cacd03eb9dc9e256ddfe2cfcbbb90e8edea06057 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "7a2d4ce7ac3752505157e416e4e0d666a54b1d5d8601701b7e7e5e30bec181b4" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "219ffb4ca59b4338117857db56d3ffadbde3169bcaf1136f5f4d4656f4a2372d" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "42d545155a26b20d74f5107648dbb3382dbbc84dc3f1adc767040359e57a1345" + url: "https://pub.dev" + source: hosted + version: "0.7.1" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fce28f9..8d0bc76 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,9 +31,6 @@ dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 http: ^0.13.5 http_parser: ^4.0.2 @@ -60,6 +57,7 @@ dependencies: hive: ^2.2.3 hive_flutter: ^1.1.0 file_picker: ^5.3.0 + record: ^4.4.4 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 7d8bb4d..c228598 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 2d0eeb9..b554658 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color flutter_secure_storage_windows + record_windows url_launcher_windows )