feat: polish chat composer feedback

This commit is contained in:
kris
2026-03-27 14:26:57 +08:00
parent 63ceef9871
commit ae571a76ff
4 changed files with 144 additions and 11 deletions

View File

@@ -229,6 +229,15 @@ public final class BossUi {
return wrapper;
}
public static LinearLayout buildPendingOutgoingMessageBubble(
Context context,
String senderLabel,
String body
) {
String effectiveSender = TextUtils.isEmpty(senderLabel) ? "" : senderLabel;
return buildMessageBubble(context, effectiveSender, body, "发送中", true, null);
}
public static TextView buildMessagePlaceholder(Context context, String text) {
TextView placeholder = new TextView(context);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(

View File

@@ -0,0 +1,13 @@
package com.hyzq.boss;
public final class ProjectChatUiState {
private ProjectChatUiState() {}
public static boolean canSend(String text, boolean sending) {
return !sending && text != null && !text.trim().isEmpty();
}
public static boolean shouldAutoScroll(boolean nearBottom, boolean forced) {
return nearBottom || forced;
}
}

View File

@@ -2,7 +2,9 @@ package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
@@ -24,6 +26,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
private EditText composerInput;
private Button composerSendButton;
private ScrollView chatScrollView;
private View pendingOutgoingBubble;
private boolean composerSending;
private boolean renderNearBottom;
private boolean renderForcedScrollToBottom;
@Override
protected int getLayoutResId() {
@@ -43,16 +49,35 @@ public class ProjectDetailActivity extends BossScreenActivity {
configureScreen(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
hideHeaderAction();
composerSendButton.setOnClickListener(v -> sendTextMessageFromComposer());
reload();
composerInput.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
updateComposerSendButtonState();
}
@Override
public void afterTextChanged(Editable s) {}
});
updateComposerSendButtonState();
reload(true);
}
@Override
protected void reload() {
reload(false);
}
private void reload(boolean forcedScrollToBottom) {
if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId");
finish();
return;
}
renderNearBottom = isChatNearBottom();
renderForcedScrollToBottom = forcedScrollToBottom;
setRefreshing(true);
executor.execute(() -> {
try {
@@ -64,7 +89,13 @@ public class ProjectDetailActivity extends BossScreenActivity {
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage()));
composerSending = false;
updateComposerSendButtonState();
if (pendingOutgoingBubble == null) {
replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage()));
} else {
showMessage("项目详情刷新失败:" + error.getMessage());
}
});
}
});
@@ -76,9 +107,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (composerInput != null) {
composerInput.setEnabled(!refreshing);
}
if (composerSendButton != null) {
composerSendButton.setEnabled(!refreshing);
}
updateComposerSendButtonState();
}
private void renderProject(JSONObject payload) {
@@ -91,6 +120,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
renderQuickActions();
replaceContent();
pendingOutgoingBubble = null;
JSONArray messages = project == null ? null : project.optJSONArray("messages");
if (messages != null && messages.length() > 0) {
@@ -113,7 +143,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
setRefreshing(false);
scrollChatToBottom();
if (ProjectChatUiState.shouldAutoScroll(renderNearBottom, renderForcedScrollToBottom)) {
scrollChatToBottom();
}
}
private void renderQuickActions() {
@@ -155,15 +187,23 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (composerInput == null) {
return;
}
sendProjectMessage("text", composerInput.getText().toString().trim());
}
private void sendProjectMessage(String kind, String body) {
String body = composerInput.getText() == null ? "" : composerInput.getText().toString().trim();
if (body.isEmpty()) {
showMessage("请输入消息内容");
return;
}
if (!ProjectChatUiState.canSend(body, isComposerBusy())) {
return;
}
sendProjectMessage("text", body);
}
private void sendProjectMessage(String kind, String body) {
composerSending = true;
updateComposerSendButtonState();
setRefreshing(true);
appendPendingOutgoingBubble(body);
scrollChatToBottom();
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.sendProjectMessage(projectId, body, kind);
@@ -171,14 +211,18 @@ public class ProjectDetailActivity extends BossScreenActivity {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
composerSending = false;
composerInput.setText("");
showMessage("消息已发送");
reload();
reload(true);
});
} catch (Exception error) {
runOnUiThread(() -> {
composerSending = false;
setRefreshing(false);
removePendingOutgoingBubble();
showMessage("发送失败:" + error.getMessage());
updateComposerSendButtonState();
});
}
});
@@ -223,6 +267,50 @@ public class ProjectDetailActivity extends BossScreenActivity {
chatScrollView.post(() -> chatScrollView.fullScroll(View.FOCUS_DOWN));
}
private void appendPendingOutgoingBubble(String body) {
if (contentLayout == null) {
return;
}
removePendingOutgoingBubble();
if (contentLayout.getChildCount() == 1 && contentLayout.getChildAt(0) instanceof android.widget.TextView) {
contentLayout.removeAllViews();
}
String senderLabel = TextUtils.isEmpty(apiClient.getDisplayName()) ? "" : apiClient.getDisplayName();
pendingOutgoingBubble = BossUi.buildPendingOutgoingMessageBubble(this, senderLabel, body);
appendContent(pendingOutgoingBubble);
}
private void removePendingOutgoingBubble() {
if (pendingOutgoingBubble != null && pendingOutgoingBubble.getParent() != null && contentLayout != null) {
contentLayout.removeView(pendingOutgoingBubble);
}
pendingOutgoingBubble = null;
}
private void updateComposerSendButtonState() {
if (composerSendButton == null || composerInput == null) {
return;
}
String body = composerInput.getText() == null ? "" : composerInput.getText().toString();
composerSendButton.setEnabled(ProjectChatUiState.canSend(body, isComposerBusy()));
}
private boolean isComposerBusy() {
return composerSending || (refreshLayout != null && refreshLayout.isRefreshing());
}
private boolean isChatNearBottom() {
if (chatScrollView == null || chatScrollView.getChildCount() == 0 || chatScrollView.getHeight() == 0) {
return true;
}
View child = chatScrollView.getChildAt(0);
if (child == null || child.getHeight() == 0) {
return true;
}
int remainingScroll = child.getBottom() - (chatScrollView.getScrollY() + chatScrollView.getHeight());
return remainingScroll <= BossUi.dp(this, 96);
}
private boolean isOutgoingMessage(String senderLabel) {
if (TextUtils.isEmpty(senderLabel)) {
return false;

View File

@@ -0,0 +1,23 @@
package com.hyzq.boss;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class ProjectChatUiStateTest {
@Test
public void sendEnabled_requiresTextAndNotBusy() {
assertFalse(ProjectChatUiState.canSend("", false));
assertFalse(ProjectChatUiState.canSend(" ", false));
assertFalse(ProjectChatUiState.canSend("你好", true));
assertTrue(ProjectChatUiState.canSend("你好", false));
}
@Test
public void shouldAutoScroll_onlyWhenNearBottomOrForced() {
assertTrue(ProjectChatUiState.shouldAutoScroll(true, false));
assertTrue(ProjectChatUiState.shouldAutoScroll(false, true));
assertFalse(ProjectChatUiState.shouldAutoScroll(false, false));
}
}