diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java index e92ae6e..f115cc7 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -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( diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java new file mode 100644 index 0000000..47e9d1d --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java @@ -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; + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index 64baa0d..51a85fe 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -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; diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java new file mode 100644 index 0000000..e5bb949 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java @@ -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)); + } +}