Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
639 changes: 605 additions & 34 deletions apps/mobile_chat_app/lib/features/chat/chat_navigation_page.dart

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions apps/mobile_chat_app/lib/features/chat/chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import 'note_api_service.dart';
import 'site_publish_api_service.dart';
import 'todo_api_service.dart';
import 'widgets/composer_bar.dart';
import 'widgets/composer_pasted_image.dart';
import 'widgets/message_list.dart';
import '../../services/authenticated_api_client.dart';

Expand Down Expand Up @@ -2552,6 +2553,31 @@ class _ChatScreenState extends State<ChatScreen> {
}
}

Future<void> _attachPastedImageToDraft(ComposerPastedImage image) async {
if (!mounted || _isUploadingAttachment || _isSending) return;
final filename = image.filename.trim().isEmpty
? 'pasted-image.png'
: image.filename.trim();
final mimeType =
image.mimeType.trim().isEmpty ? 'image/png' : image.mimeType.trim();
final generation = ++_imageUploadGeneration;
setState(() {
_isUploadingAttachment = true;
_draftUpload = ComposerDraftUpload(
filename: filename,
mimeType: mimeType,
dataBase64: image.dataBase64,
isUploading: true,
);
});
await _uploadDraftImage(
filename: filename,
mimeType: mimeType,
dataBase64: image.dataBase64,
generation: generation,
);
}

Future<void> _uploadDraftImage({
required String filename,
required String mimeType,
Expand Down Expand Up @@ -3227,6 +3253,7 @@ class _ChatScreenState extends State<ChatScreen> {
closeOnChannelSelected: closeOnChannelSelected,
todoApiService: _todoApiService,
noteApiService: _noteApiService,
onResourceChanged: _refreshResources,
onActionSelected: (action) {
switch (action) {
case ChatNavigationAction.appSettings:
Expand Down Expand Up @@ -3541,6 +3568,8 @@ class _ChatScreenState extends State<ChatScreen> {
onSend: _isSending ? null : _sendMessage,
onAttachImage:
_isUploadingAttachment ? null : _attachImageToDraft,
onPasteImage:
_isUploadingAttachment ? null : _attachPastedImageToDraft,
onCancelDraftUpload: _cancelDraftImageUpload,
onRetryDraftUpload: _retryDraftImageUpload,
onRemoveAttachment: _removePendingAttachment,
Expand Down
23 changes: 23 additions & 0 deletions apps/mobile_chat_app/lib/features/chat/note_api_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,29 @@ class NoteApiService {
jsonDecode(response.body) as Map<String, dynamic>);
}

Future<NoteDetail> updateNote({
required String noteId,
String? title,
String? body,
bool? isPublished,
}) async {
final patch = <String, dynamic>{};
if (title != null) patch['title'] = title;
if (body != null) patch['body'] = body;
if (isPublished != null) patch['isPublished'] = isPublished;

final response = await _apiClient.patch(
_noteUri(noteId),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(patch),
);
if (response.statusCode != 200) {
throw Exception('Failed to update note: ${response.statusCode}');
}
return NoteDetail.fromJson(
jsonDecode(response.body) as Map<String, dynamic>);
}

void dispose() {
if (_ownsClient) {
_apiClient.close();
Expand Down
116 changes: 92 additions & 24 deletions apps/mobile_chat_app/lib/features/chat/widgets/composer_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,25 @@ import 'dart:convert';
import 'package:chat_domain/chat_domain.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import '../../auth/auth_service.dart';
import '../../settings/llm_config_service.dart';
import '../chat_message.dart';
import 'composer_paste_image.dart';
import 'composer_pasted_image.dart';

/// Actions available in the composer popup menu.
enum ComposerMenuAction { model, info }

class _SubmitComposerIntent extends Intent {
const _SubmitComposerIntent();
}

class _InsertComposerNewlineIntent extends Intent {
const _InsertComposerNewlineIntent();
}

class ComposerAtAction {
const ComposerAtAction({
required this.value,
Expand Down Expand Up @@ -58,6 +69,7 @@ class ComposerBar extends StatefulWidget {
this.draftUpload,
this.onSend,
this.onAttachImage,
this.onPasteImage,
this.onCancelDraftUpload,
this.onRetryDraftUpload,
this.onRemoveAttachment,
Expand All @@ -81,6 +93,7 @@ class ComposerBar extends StatefulWidget {
final ComposerDraftUpload? draftUpload;
final void Function(String text)? onSend;
final VoidCallback? onAttachImage;
final void Function(ComposerPastedImage image)? onPasteImage;
final VoidCallback? onCancelDraftUpload;
final VoidCallback? onRetryDraftUpload;
final void Function(String mediaId)? onRemoveAttachment;
Expand All @@ -106,6 +119,7 @@ class _ComposerBarState extends State<ComposerBar>
with SingleTickerProviderStateMixin {
final _controller = TextEditingController();
final _focusNode = FocusNode();
late final ComposerPasteImageSubscription _pasteImageSubscription;
late AnimationController _spinController;
bool _hasDraft = false;

Expand All @@ -118,6 +132,10 @@ class _ComposerBarState extends State<ComposerBar>
)..repeat();
_focusNode.addListener(() => setState(() {}));
_controller.addListener(_onDraftChanged);
_pasteImageSubscription = listenForComposerPastedImages(
focusNode: _focusNode,
onImage: _handlePastedImage,
);
}

void _onDraftChanged() {
Expand Down Expand Up @@ -153,6 +171,29 @@ class _ComposerBarState extends State<ComposerBar>
});
}

void _handlePastedImage(ComposerPastedImage image) {
if (!mounted ||
widget.onPasteImage == null ||
widget.draftUpload != null ||
widget.isStreaming ||
widget.onSend == null) {
return;
}
widget.onPasteImage!(image);
}

void _insertNewLineAtCursor() {
final value = _controller.value;
final selection = value.selection;
final start = selection.isValid ? selection.start : value.text.length;
final end = selection.isValid ? selection.end : value.text.length;
final nextText = value.text.replaceRange(start, end, '\n');
_controller.value = TextEditingValue(
text: nextText,
selection: TextSelection.collapsed(offset: start + 1),
);
}

void _insertSlashCommand(String command) {
final trimmed = command.trim();
if (trimmed.isEmpty) return;
Expand All @@ -179,6 +220,7 @@ class _ComposerBarState extends State<ComposerBar>

@override
void dispose() {
_pasteImageSubscription.cancel();
_spinController.dispose();
_controller.removeListener(_onDraftChanged);
_controller.dispose();
Expand Down Expand Up @@ -251,30 +293,56 @@ class _ComposerBarState extends State<ComposerBar>
),
),
),
TextField(
controller: _controller,
focusNode: _focusNode,
enabled: true,
maxLines: 5,
minLines: 1,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submit(),
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: chatColors.onMessageAssistant),
decoration: InputDecoration(
hintText: 'Ask Bricks to create something…',
hintStyle: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: chatColors.composerPlaceholder),
border: InputBorder.none,
contentPadding: const EdgeInsets.fromLTRB(
BricksSpacing.md,
6,
BricksSpacing.md,
2,
Shortcuts(
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.enter):
_SubmitComposerIntent(),
SingleActivator(LogicalKeyboardKey.enter, shift: true):
_InsertComposerNewlineIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
_SubmitComposerIntent:
CallbackAction<_SubmitComposerIntent>(
onInvoke: (_) {
_submit();
return null;
},
),
_InsertComposerNewlineIntent:
CallbackAction<_InsertComposerNewlineIntent>(
onInvoke: (_) {
_insertNewLineAtCursor();
return null;
},
),
},
child: TextField(
controller: _controller,
focusNode: _focusNode,
enabled: true,
maxLines: 5,
minLines: 1,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submit(),
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: chatColors.onMessageAssistant),
decoration: InputDecoration(
hintText: 'Ask Bricks to create something…',
hintStyle: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: chatColors.composerPlaceholder),
border: InputBorder.none,
contentPadding: const EdgeInsets.fromLTRB(
BricksSpacing.md,
6,
BricksSpacing.md,
2,
),
),
),
),
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import 'package:flutter/widgets.dart';

import 'composer_paste_image_stub.dart'
if (dart.library.html) 'composer_paste_image_web.dart' as impl;
import 'composer_pasted_image.dart';

ComposerPasteImageSubscription listenForComposerPastedImages({
required FocusNode focusNode,
required ValueChanged<ComposerPastedImage> onImage,
}) {
return impl.listenForComposerPastedImages(
focusNode: focusNode,
onImage: onImage,
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'package:flutter/widgets.dart';

import 'composer_pasted_image.dart';

class _NoopComposerPasteImageSubscription
implements ComposerPasteImageSubscription {
const _NoopComposerPasteImageSubscription();

@override
void cancel() {}
}

ComposerPasteImageSubscription listenForComposerPastedImages({
required FocusNode focusNode,
required ValueChanged<ComposerPastedImage> onImage,
}) {
return const _NoopComposerPasteImageSubscription();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// ignore_for_file: deprecated_member_use

import 'dart:convert';
import 'dart:html' as html;
import 'dart:typed_data';

import 'package:flutter/widgets.dart';

import 'composer_pasted_image.dart';

class _WebComposerPasteImageSubscription
implements ComposerPasteImageSubscription {
_WebComposerPasteImageSubscription(this._listener) {
html.document.addEventListener('paste', _listener);
}

final html.EventListener _listener;

@override
void cancel() {
html.document.removeEventListener('paste', _listener);
}
}

ComposerPasteImageSubscription listenForComposerPastedImages({
required FocusNode focusNode,
required ValueChanged<ComposerPastedImage> onImage,
}) {
void listener(html.Event event) {
if (!focusNode.hasFocus || event is! html.ClipboardEvent) return;
final items = event.clipboardData?.items;
if (items == null) return;

final itemCount = items.length ?? 0;
for (var index = 0; index < itemCount; index++) {
final item = items[index];
final mimeType = item.type?.toLowerCase() ?? '';
if (!mimeType.startsWith('image/')) continue;

final file = item.getAsFile();
if (file == null) continue;
event.preventDefault();

final reader = html.FileReader();
reader.onLoad.first.then((_) {
final result = reader.result;
Uint8List bytes;
if (result is ByteBuffer) {
bytes = Uint8List.view(result);
} else if (result is Uint8List) {
bytes = result;
} else {
return;
}
final extension = _extensionForMimeType(mimeType);
final name = file.name.trim().isEmpty
? 'pasted-image.$extension'
: file.name.trim();
onImage(
ComposerPastedImage(
filename: name,
mimeType: mimeType,
dataBase64: base64Encode(bytes),
),
);
});
reader.readAsArrayBuffer(file);
return;
}
}

return _WebComposerPasteImageSubscription(listener);
}

String _extensionForMimeType(String mimeType) {
switch (mimeType) {
case 'image/jpeg':
return 'jpg';
case 'image/webp':
return 'webp';
case 'image/gif':
return 'gif';
case 'image/png':
default:
return 'png';
}
}
Loading
Loading