diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 93896417..06bc175f 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -310,6 +310,7 @@ "settingsServerUrl": "Server URL", "settingsApplied": "The settings has been applied.", "notifications": "Notifications", + "notificationsDescription": "See what's happended related to you recently.", "posts": "Posts", "settingsBackgroundImage": "Background Image", "settingsBackgroundImageClear": "Clear Background Image", @@ -1135,6 +1136,7 @@ "installUpdate": "Install update", "openReleasePage": "Open release page", "postCompose": "Compose Post", + "postComposeDescription": "Compose a new post", "postPublish": "Publish Post", "restoreDraftTitle": "Restore Draft", "restoreDraftMessage": "A draft was found. Do you want to restore it?", diff --git a/lib/screens/thought/think_sheet.dart b/lib/screens/thought/think_sheet.dart index fd0c6208..1dab102f 100644 --- a/lib/screens/thought/think_sheet.dart +++ b/lib/screens/thought/think_sheet.dart @@ -10,17 +10,20 @@ import "package:island/widgets/thought/thought_shared.dart"; import "package:material_symbols_icons/material_symbols_icons.dart"; class ThoughtSheet extends HookConsumerWidget { + final String? initialMessage; final List> attachedMessages; final List attachedPosts; const ThoughtSheet({ super.key, + this.initialMessage, this.attachedMessages = const [], this.attachedPosts = const [], }); static Future show( BuildContext context, { + String? initialMessage, List> attachedMessages = const [], List attachedPosts = const [], }) { @@ -28,11 +31,11 @@ class ThoughtSheet extends HookConsumerWidget { context: context, isScrollControlled: true, useSafeArea: true, - builder: - (context) => ThoughtSheet( - attachedMessages: attachedMessages, - attachedPosts: attachedPosts, - ), + builder: (context) => ThoughtSheet( + initialMessage: initialMessage, + attachedMessages: attachedMessages, + attachedPosts: attachedPosts, + ), ); } @@ -40,6 +43,7 @@ class ThoughtSheet extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final chatState = useThoughtChat( ref, + initialMessage: initialMessage, attachedMessages: attachedMessages, attachedPosts: attachedPosts, ); @@ -75,31 +79,30 @@ class ThoughtSheet extends HookConsumerWidget { return status ? chatInterface : Column( - children: [ - MaterialBanner( - leading: const Icon(Symbols.error), - content: const Text( - 'You have unpaid orders. Please settle your payment to continue using the service.', - style: TextStyle(fontWeight: FontWeight.bold), - ), - actions: [ - TextButton( - onPressed: () { - retry(); - }, - child: Text('retry'.tr()), + children: [ + MaterialBanner( + leading: const Icon(Symbols.error), + content: const Text( + 'You have unpaid orders. Please settle your payment to continue using the service.', + style: TextStyle(fontWeight: FontWeight.bold), ), - ], - ), - Expanded(child: chatInterface), - ], - ); + actions: [ + TextButton( + onPressed: () { + retry(); + }, + child: Text('retry'.tr()), + ), + ], + ), + Expanded(child: chatInterface), + ], + ); }, - orElse: - () => ThoughtChatInterface( - attachedMessages: attachedMessages, - attachedPosts: attachedPosts, - ), + orElse: () => ThoughtChatInterface( + attachedMessages: attachedMessages, + attachedPosts: attachedPosts, + ), ), ); } diff --git a/lib/services/event_bus.dart b/lib/services/event_bus.dart index 023967c1..50e82f27 100644 --- a/lib/services/event_bus.dart +++ b/lib/services/event_bus.dart @@ -38,3 +38,16 @@ class ShowComposeSheetEvent { class ShowNotificationSheetEvent { const ShowNotificationSheetEvent(); } + +/// Event fired to show the thought sheet +class ShowThoughtSheetEvent { + final String? initialMessage; + final List> attachedMessages; + final List attachedPosts; + + const ShowThoughtSheetEvent({ + this.initialMessage, + this.attachedMessages = const [], + this.attachedPosts = const [], + }); +} diff --git a/lib/widgets/app_wrapper.dart b/lib/widgets/app_wrapper.dart index 7004db8f..a793a602 100644 --- a/lib/widgets/app_wrapper.dart +++ b/lib/widgets/app_wrapper.dart @@ -19,6 +19,7 @@ import 'package:island/widgets/content/network_status_sheet.dart'; import 'package:island/widgets/tour/tour.dart'; import 'package:island/widgets/post/compose_sheet.dart'; import 'package:island/screens/notification.dart'; +import 'package:island/screens/thought/think_sheet.dart'; import 'package:island/services/event_bus.dart'; import 'package:tray_manager/tray_manager.dart'; import 'package:window_manager/window_manager.dart'; @@ -38,6 +39,7 @@ class _AppWrapperState extends ConsumerState StreamSubscription? composeSheetSubs; StreamSubscription? notificationSheetSubs; + StreamSubscription? thoughtSheetSubs; @override void initState() { @@ -70,6 +72,12 @@ class _AppWrapperState extends ConsumerState } }); + thoughtSheetSubs = eventBus.on().listen((event) { + if (mounted) { + _showThoughtSheet(event); + } + }); + final initialUrl = await protocolHandler.getInitialUrl(); if (initialUrl != null && mounted) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -87,6 +95,7 @@ class _AppWrapperState extends ConsumerState ntySubs?.cancel(); composeSheetSubs?.cancel(); notificationSheetSubs?.cancel(); + thoughtSheetSubs?.cancel(); super.dispose(); } @@ -154,6 +163,15 @@ class _AppWrapperState extends ConsumerState ); } + void _showThoughtSheet(ShowThoughtSheetEvent event) { + ThoughtSheet.show( + context, + initialMessage: event.initialMessage, + attachedMessages: event.attachedMessages, + attachedPosts: event.attachedPosts, + ); + } + void _handleDeepLink(Uri uri, WidgetRef ref) async { String path = '/${uri.host}${uri.path}'; diff --git a/lib/widgets/cmp/pattle.dart b/lib/widgets/cmp/pattle.dart index c10e6fbe..28bea3f1 100644 --- a/lib/widgets/cmp/pattle.dart +++ b/lib/widgets/cmp/pattle.dart @@ -29,16 +29,17 @@ class CommandPattleWidget extends HookConsumerWidget { static List _getSpecialActions(BuildContext context) { return [ SpecialAction( - name: 'Compose Post', - description: 'Create a new post', + name: 'postCompose'.tr(), + description: 'postComposeDescription'.tr(), icon: Symbols.edit, action: () { eventBus.fire(const ShowComposeSheetEvent()); }, ), SpecialAction( - name: 'Notifications', - description: 'View your notifications', + name: 'notifications'.tr(), + description: 'notificationsDescription'.tr(), + searchableAliases: ['notifications', 'alert', 'bell'], icon: Symbols.notifications, action: () { eventBus.fire(const ShowNotificationSheetEvent()); @@ -149,7 +150,7 @@ class CommandPattleWidget extends HookConsumerWidget { filteredChats.isEmpty && filteredSpecialActions.isEmpty && filteredRoutes.isEmpty - ? _getFallbackActions(searchQuery.value) + ? _getFallbackActions(context, searchQuery.value) : []; // Combine results: fallbacks first, then chats, special actions, routes @@ -202,17 +203,7 @@ class CommandPattleWidget extends HookConsumerWidget { if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { final item = allResults[focusedIndex.value ?? 0]; - if (item is SnChatRoom) { - _navigateToChat(context, ref, item); - } else if (item is SpecialAction) { - onDismiss(); - item.action(); - } else if (item is RouteItem) { - _navigateToRoute(context, ref, item); - } else if (item is FallbackAction) { - onDismiss(); - item.action(); - } + _executeItem(context, ref, item); } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { if (allResults.isNotEmpty) { if (focusedIndex.value == null) { @@ -291,6 +282,13 @@ class CommandPattleWidget extends HookConsumerWidget { leading: CircleAvatar( child: const Icon(Symbols.keyboard_command_key), ).padding(horizontal: 8), + onSubmitted: !isDesktop() && allResults.isNotEmpty + ? (value) => _executeItem( + context, + ref, + allResults[0], + ) + : null, ), AnimatedSize( duration: const Duration(milliseconds: 200), @@ -389,43 +387,80 @@ class CommandPattleWidget extends HookConsumerWidget { ref.read(routerProvider).go(route.path); } - static List _getFallbackActions(String query) { + void _executeItem(BuildContext context, WidgetRef ref, dynamic item) { + if (item is SnChatRoom) { + _navigateToChat(context, ref, item); + } else if (item is SpecialAction) { + onDismiss(); + item.action(); + } else if (item is RouteItem) { + _navigateToRoute(context, ref, item); + } else if (item is FallbackAction) { + onDismiss(); + item.action(); + } + } + + static List _getFallbackActions( + BuildContext context, + String query, + ) { final List actions = []; // Check if query is a URL final Uri? uri = Uri.tryParse(query); - if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) { + final isValidUrl = + uri != null && (uri.scheme == 'http' || uri.scheme == 'https'); + final isDomain = RegExp( + r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$', + ).hasMatch(query); + + if (isValidUrl || isDomain) { + final finalUri = isDomain ? Uri.parse('https://$query') : uri!; actions.add( FallbackAction( name: 'Open URL', - description: 'Open $query in browser', + description: 'Open ${finalUri.toString()} in browser', icon: Symbols.open_in_new, action: () async { - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - }, - ), - ); - } else { - // Search the web - actions.add( - FallbackAction( - name: 'Search the web', - description: 'Search "$query" on Google', - icon: Symbols.search, - action: () async { - final searchUri = Uri.https('www.google.com', '/search', { - 'q': query, - }); - if (await canLaunchUrl(searchUri)) { - await launchUrl(searchUri, mode: LaunchMode.externalApplication); + if (await canLaunchUrl(finalUri)) { + await launchUrl(finalUri, mode: LaunchMode.externalApplication); } }, ), ); } + // Ask the AI + // Bugged, DO NOT USE + // actions.add( + // FallbackAction( + // name: 'Ask the AI', + // description: 'Ask "$query" to the AI', + // icon: Symbols.bubble_chart, + // action: () { + // eventBus.fire(ShowThoughtSheetEvent(initialMessage: query)); + // }, + // ), + // ); + + // Search the web + actions.add( + FallbackAction( + name: 'Search the web', + description: 'Search "$query" on Google', + icon: Symbols.search, + action: () async { + final searchUri = Uri.https('www.google.com', '/search', { + 'q': query, + }); + if (await canLaunchUrl(searchUri)) { + await launchUrl(searchUri, mode: LaunchMode.externalApplication); + } + }, + ), + ); + return actions; } } diff --git a/lib/widgets/thought/thought_content.dart b/lib/widgets/thought/thought_content.dart index 551f9d9e..f0d9a6b5 100644 --- a/lib/widgets/thought/thought_content.dart +++ b/lib/widgets/thought/thought_content.dart @@ -28,93 +28,45 @@ class ThoughtContent extends StatelessWidget { @override Widget build(BuildContext context) { - if (isStreaming) { - // Streaming text with spinner - if (streamingText.isNotEmpty) { - final isStreamingError = streamingText.startsWith('Error:'); - return Container( - padding: isStreamingError ? const EdgeInsets.all(8) : EdgeInsets.zero, - decoration: - isStreamingError - ? BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.error, - width: 1, - ), - borderRadius: BorderRadius.circular(8), - ) - : null, - child: MarkdownTextContent( - isSelectable: true, - content: streamingText, - extraBlockSyntaxList: [ProposalBlockSyntax()], - textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: - isStreamingError ? Theme.of(context).colorScheme.error : null, - ), - extraGenerators: [ - ProposalGenerator( - backgroundColor: - Theme.of(context).colorScheme.secondaryContainer, - foregroundColor: - Theme.of(context).colorScheme.onSecondaryContainer, - borderColor: Theme.of(context).colorScheme.outline, + final content = streamingText.isNotEmpty + ? streamingText + : thought != null + ? thought!.parts + .where((p) => p.type == ThinkingMessagePartType.text) + .map((p) => p.text ?? '') + .join('') + : ''; + + if (content.isEmpty) return const SizedBox.shrink(); + + final isError = content.startsWith('Error:') || _isErrorMessage; + + return Container( + padding: isError ? const EdgeInsets.all(8) : EdgeInsets.zero, + decoration: isError + ? BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.error, + width: 1, ), - ], + borderRadius: BorderRadius.circular(8), + ) + : null, + child: MarkdownTextContent( + isSelectable: true, + content: content, + extraBlockSyntaxList: [ProposalBlockSyntax()], + textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: isError ? Theme.of(context).colorScheme.error : null, + ), + extraGenerators: [ + ProposalGenerator( + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer, + borderColor: Theme.of(context).colorScheme.outline, ), - ); - } - return const SizedBox.shrink(); - } else { - // Regular thought content - render parts - if (thought!.parts.isNotEmpty) { - final textParts = thought!.parts - .where((p) => p.type == ThinkingMessagePartType.text) - .map((p) => p.text ?? '') - .join(''); - if (textParts.isNotEmpty) { - return Container( - padding: - _isErrorMessage - ? const EdgeInsets.symmetric(horizontal: 12, vertical: 4) - : EdgeInsets.zero, - decoration: - _isErrorMessage - ? BoxDecoration( - color: Theme.of( - context, - ).colorScheme.error.withOpacity(0.1), - border: Border.all( - color: Theme.of(context).colorScheme.error, - width: 1, - ), - borderRadius: BorderRadius.circular(8), - ) - : null, - child: MarkdownTextContent( - isSelectable: true, - content: textParts, - extraBlockSyntaxList: [ProposalBlockSyntax()], - textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: - _isErrorMessage - ? Theme.of(context).colorScheme.error - : null, - ), - extraGenerators: [ - ProposalGenerator( - backgroundColor: - Theme.of(context).colorScheme.secondaryContainer, - foregroundColor: - Theme.of(context).colorScheme.onSecondaryContainer, - borderColor: Theme.of(context).colorScheme.outline, - ), - ], - ), - ); - } - } - return const SizedBox.shrink(); - } + ], + ), + ); } } diff --git a/lib/widgets/thought/thought_shared.dart b/lib/widgets/thought/thought_shared.dart index 8b5c83a2..8d01e9e3 100644 --- a/lib/widgets/thought/thought_shared.dart +++ b/lib/widgets/thought/thought_shared.dart @@ -74,6 +74,7 @@ ThoughtChatState useThoughtChat( String? initialSequenceId, List? initialThoughts, String? initialTopic, + String? initialMessage, List> attachedMessages = const [], List attachedPosts = const [], VoidCallback? onSequenceIdChanged, @@ -117,11 +118,13 @@ ThoughtChatState useThoughtChat( useEffect(() { if (localThoughts.value.isNotEmpty || isStreaming.value) { WidgetsBinding.instance.addPostFrameCallback((_) { - scrollController.animateTo( - 0, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); + if (scrollController.hasClients) { + scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } }); } return null; @@ -141,10 +144,12 @@ ThoughtChatState useThoughtChat( return () => scrollController.removeListener(onScroll); }, [scrollController]); - Future sendMessage() async { - if (messageController.text.trim().isEmpty) return; + Future sendMessage({String? message}) async { + if (message == null && messageController.text.trim().isEmpty) { + return; + } - final userMessage = messageController.text.trim(); + final userMessage = message ?? messageController.text.trim(); // Add user message to local thoughts final userInfo = ref.read(userInfoProvider); @@ -177,8 +182,9 @@ ThoughtChatState useThoughtChat( accpetProposals: ['post_create'], attachedMessages: attachedMessages, attachedPosts: attachedPosts, - serviceId: - selectedServiceId.value.isNotEmpty ? selectedServiceId.value : null, + serviceId: selectedServiceId.value.isNotEmpty + ? selectedServiceId.value + : null, ); try { @@ -309,8 +315,8 @@ ThoughtChatState useThoughtChat( final now = DateTime.now(); final errorMessage = error is DioException && error.response?.data is ResponseBody - ? 'toughtParseError'.tr() - : error.toString(); + ? 'toughtParseError'.tr() + : error.toString(); final errorThought = SnThinkingThought( id: 'error-${DateTime.now().millisecondsSinceEpoch}', parts: [ @@ -368,6 +374,15 @@ ThoughtChatState useThoughtChat( } } + useEffect(() { + if (initialMessage?.isNotEmpty ?? false) { + WidgetsBinding.instance.addPostFrameCallback((_) { + sendMessage(message: initialMessage); + }); + } + return null; + }, [initialMessage]); + return ThoughtChatState( sequenceId: sequenceId, localThoughts: localThoughts, @@ -388,6 +403,7 @@ class ThoughtChatInterface extends HookConsumerWidget { final List? initialThoughts; final String? initialSequenceId; final String? initialTopic; + final String? initialMessage; final List> attachedMessages; final List attachedPosts; final bool isDisabled; @@ -397,6 +413,7 @@ class ThoughtChatInterface extends HookConsumerWidget { this.initialThoughts, this.initialSequenceId, this.initialTopic, + this.initialMessage, this.attachedMessages = const [], this.attachedPosts = const [], this.isDisabled = false, @@ -415,6 +432,7 @@ class ThoughtChatInterface extends HookConsumerWidget { initialSequenceId: initialSequenceId, initialThoughts: initialThoughts, initialTopic: initialTopic, + initialMessage: initialMessage, attachedMessages: attachedMessages, attachedPosts: attachedPosts, ); @@ -445,84 +463,78 @@ class ThoughtChatInterface extends HookConsumerWidget { Expanded( child: previousInputHeight != null && - previousInputHeight != inputHeight.value - ? TweenAnimationBuilder( - tween: Tween( - begin: previousInputHeight, - end: inputHeight.value, - ), - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - builder: - (context, height, child) => - SuperListView.builder( - listController: chatState.listController, - controller: chatState.scrollController, - padding: EdgeInsets.only( - top: 16, - bottom: - MediaQuery.of( - context, - ).padding.bottom + - 8 + - height, - ), - reverse: true, - itemCount: - chatState.localThoughts.value.length + - (chatState.isStreaming.value ? 1 : 0), - itemBuilder: (context, index) { - if (chatState.isStreaming.value && - index == 0) { - return ThoughtItem( - isStreaming: true, - streamingItems: - chatState.streamingItems.value, - ); - } - final thoughtIndex = - chatState.isStreaming.value - ? index - 1 - : index; - final thought = - chatState - .localThoughts - .value[thoughtIndex]; - return ThoughtItem(thought: thought); - }, - ), - ) - : SuperListView.builder( - listController: chatState.listController, - controller: chatState.scrollController, - padding: EdgeInsets.only( - top: 16, - bottom: - MediaQuery.of(context).padding.bottom + - 8 + - inputHeight.value, - ), - reverse: true, - itemCount: - chatState.localThoughts.value.length + - (chatState.isStreaming.value ? 1 : 0), - itemBuilder: (context, index) { - if (chatState.isStreaming.value && index == 0) { - return ThoughtItem( - isStreaming: true, - streamingItems: - chatState.streamingItems.value, - ); - } - final thoughtIndex = - chatState.isStreaming.value + previousInputHeight != inputHeight.value + ? TweenAnimationBuilder( + tween: Tween( + begin: previousInputHeight, + end: inputHeight.value, + ), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + builder: (context, height, child) => + SuperListView.builder( + listController: chatState.listController, + controller: chatState.scrollController, + padding: EdgeInsets.only( + top: 16, + bottom: + MediaQuery.of(context).padding.bottom + + 8 + + height, + ), + reverse: true, + itemCount: + chatState.localThoughts.value.length + + (chatState.isStreaming.value ? 1 : 0), + itemBuilder: (context, index) { + if (chatState.isStreaming.value && + index == 0) { + return ThoughtItem( + isStreaming: true, + streamingItems: + chatState.streamingItems.value, + ); + } + final thoughtIndex = + chatState.isStreaming.value ? index - 1 : index; - final thought = - chatState.localThoughts.value[thoughtIndex]; - return ThoughtItem(thought: thought); - }, + final thought = chatState + .localThoughts + .value[thoughtIndex]; + return ThoughtItem(thought: thought); + }, + ), + ) + : SuperListView.builder( + listController: chatState.listController, + controller: chatState.scrollController, + padding: EdgeInsets.only( + top: 16, + bottom: + MediaQuery.of(context).padding.bottom + + 8 + + inputHeight.value, ), + reverse: true, + itemCount: + chatState.localThoughts.value.length + + (chatState.isStreaming.value ? 1 : 0), + itemBuilder: (context, index) { + if (chatState.isStreaming.value && index == 0) { + return ThoughtItem( + isStreaming: true, + streamingItems: chatState.streamingItems.value, + ); + } + final thoughtIndex = chatState.isStreaming.value + ? index - 1 + : index; + final thought = + chatState.localThoughts.value[thoughtIndex]; + return ThoughtItem(thought: thought); + }, + ), ), ], ), @@ -531,35 +543,31 @@ class ThoughtChatInterface extends HookConsumerWidget { // Bottom gradient - appears when scrolling towards newer thoughts (behind thought input) AnimatedBuilder( animation: chatState.bottomGradientNotifier.value, - builder: - (context, child) => Positioned( - left: 0, - right: 0, - bottom: 0, - child: Opacity( - opacity: chatState.bottomGradientNotifier.value.value, - child: Container( - height: math.min( - MediaQuery.of(context).size.height * 0.1, - 128, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Theme.of( - context, - ).colorScheme.surfaceContainer.withOpacity(0.8), - Theme.of( - context, - ).colorScheme.surfaceContainer.withOpacity(0.0), - ], - ), - ), + builder: (context, child) => Positioned( + left: 0, + right: 0, + bottom: 0, + child: Opacity( + opacity: chatState.bottomGradientNotifier.value.value, + child: Container( + height: math.min(MediaQuery.of(context).size.height * 0.1, 128), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Theme.of( + context, + ).colorScheme.surfaceContainer.withOpacity(0.8), + Theme.of( + context, + ).colorScheme.surfaceContainer.withOpacity(0.0), + ], ), ), ), + ), + ), ), // Thought Input positioned above gradient (higher z-index) Positioned( @@ -746,58 +754,42 @@ class ThoughtInput extends HookWidget { maxLines: 5, minLines: 1, textInputAction: TextInputAction.send, - onSubmitted: - (!isStreaming && !isDisabled) - ? (_) => onSend() - : null, + onSubmitted: (!isStreaming && !isDisabled) + ? (_) => onSend() + : null, ), ), - IconButton( - icon: Icon(isStreaming ? Symbols.stop : Icons.send), - color: Theme.of(context).colorScheme.primary, - onPressed: (!isStreaming && !isDisabled) ? onSend : null, - ), - ], - ), - Padding( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), - child: Row( - children: [ - if (services.isNotEmpty) - DropdownButtonHideUnderline( - child: DropdownButton2( - value: - selectedServiceId.value.isEmpty + Row( + children: [ + if (services.isNotEmpty) + SizedBox( + height: 40, + child: DropdownButtonHideUnderline( + child: DropdownButton2( + value: selectedServiceId.value.isEmpty ? null : selectedServiceId.value, - customButton: Container( - padding: EdgeInsets.all(4), - decoration: BoxDecoration( - border: BoxBorder.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - borderRadius: const BorderRadius.all( - Radius.circular(16), - ), - ), - child: Row( - spacing: 8, - children: [ - const Icon( - Symbols.network_intelligence, - size: 20, + customButton: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, ), - Text(selectedServiceId.value), - const Icon( - Symbols.keyboard_arrow_down, - size: 14, - ).padding(right: 4), - ], - ).padding(vertical: 2, horizontal: 6), - ), - items: - services + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(24), + ), + ), + child: Row( + spacing: 8, + children: [ + Text(selectedServiceId.value), + const Icon( + Symbols.keyboard_arrow_down, + size: 14, + ).padding(right: 4), + ], + ).padding(vertical: 2, horizontal: 6), + ), + items: services .map( (service) => DropdownMenuItem( value: service.serviceId, @@ -807,61 +799,66 @@ class ThoughtInput extends HookWidget { children: [ Text( service.serviceId, - style: DefaultTextStyle.of( - context, - ).style.copyWith( - fontSize: 14, - fontWeight: FontWeight.w500, - ), + style: DefaultTextStyle.of(context) + .style + .copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + ), ), Text( - 'Rate: ${service.billingMultiplier}x, Level: P${service.perkLevel}', - style: DefaultTextStyle.of( - context, - ).style.copyWith( - fontSize: 12, - color: - Theme.of(context) + '${service.billingMultiplier}x, T${service.perkLevel}', + style: DefaultTextStyle.of(context) + .style + .copyWith( + fontSize: 12, + color: Theme.of(context) .colorScheme .onSurfaceVariant, - ), + ), ), ], ), ), ) .toList(), - onChanged: - !isStreaming && !isDisabled + onChanged: !isStreaming && !isDisabled ? (value) { - if (value != null) { - selectedServiceId.value = value; + if (value != null) { + selectedServiceId.value = value; + } } - } : null, - hint: const Text('Select Service'), - isDense: true, - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all( - Radius.circular(16), + hint: const Text('Select Service'), + isDense: true, + buttonStyleData: ButtonStyleData( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(24), + ), + ), + ), + menuItemStyleData: MenuItemStyleData( + selectedMenuItemBuilder: (context, child) { + return child; + }, + height: 56, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), ), ), ), - menuItemStyleData: MenuItemStyleData( - selectedMenuItemBuilder: (context, child) { - return child; - }, - height: 56, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, - ), - ), ), - ), - ], - ), + ], + ), + IconButton( + icon: Icon(isStreaming ? Symbols.stop : Icons.send), + color: Theme.of(context).colorScheme.primary, + onPressed: (!isStreaming && !isDisabled) ? onSend : null, + ), + ], ), ], ), @@ -923,42 +920,40 @@ class ThoughtItem extends StatelessWidget { } List buildWidgetsList() { - final List items = - isStreaming - ? (streamingItems ?? []) - : thought!.parts.map((p) { - String type; - switch (p.type) { - case ThinkingMessagePartType.text: - type = 'text'; - break; - case ThinkingMessagePartType.functionCall: - type = 'function_call'; - break; - case ThinkingMessagePartType.functionResult: - type = 'function_result'; - break; - } - return StreamItem( - type, - p.type == ThinkingMessagePartType.text - ? p.text ?? '' - : p.functionCall ?? p.functionResult, - ); - }).toList(); + final List items = isStreaming + ? (streamingItems ?? []) + : thought!.parts.map((p) { + String type; + switch (p.type) { + case ThinkingMessagePartType.text: + type = 'text'; + break; + case ThinkingMessagePartType.functionCall: + type = 'function_call'; + break; + case ThinkingMessagePartType.functionResult: + type = 'function_result'; + break; + } + return StreamItem( + type, + p.type == ThinkingMessagePartType.text + ? p.text ?? '' + : p.functionCall ?? p.functionResult, + ); + }).toList(); final isAI = isStreaming || (!isStreaming && thought!.role == ThinkingThoughtRole.assistant); - final List> proposals = - !isStreaming - ? _extractProposals( - thought!.parts - .where((p) => p.type == ThinkingMessagePartType.text) - .map((p) => p.text ?? '') - .join(), - ) - : []; + final List> proposals = !isStreaming + ? _extractProposals( + thought!.parts + .where((p) => p.type == ThinkingMessagePartType.text) + .map((p) => p.text ?? '') + .join(), + ) + : []; final List widgets = []; String currentText = ''; @@ -986,10 +981,9 @@ class ThoughtItem extends StatelessWidget { isFinish: result != null, isStreaming: isStreaming, callData: JsonEncoder.withIndent(' ').convert(item.data.toJson()), - resultData: - result != null - ? JsonEncoder.withIndent(' ').convert(result.data.toJson()) - : null, + resultData: result != null + ? JsonEncoder.withIndent(' ').convert(result.data.toJson()) + : null, ), ); } else if (item.type == 'function_result') {