diff --git a/android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java b/android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java index bd5c5a8..25f1567 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java @@ -28,7 +28,7 @@ public abstract class BossScreenActivity extends AppCompatActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.activity_screen); + setContentView(getLayoutResId()); apiClient = new BossApiClient(this); backButton = findViewById(R.id.screen_back_button); @@ -44,6 +44,10 @@ public abstract class BossScreenActivity extends AppCompatActivity { refreshLayout.setOnRefreshListener(this::reload); } + protected int getLayoutResId() { + return R.layout.activity_screen; + } + @Override protected void onDestroy() { executor.shutdownNow(); 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 618c02b..e92ae6e 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -171,6 +171,80 @@ public final class BossUi { return buildCard(context, "暂无内容", text, "下拉或点击顶部刷新按钮重试。"); } + public static LinearLayout buildMessageBubble( + Context context, + String senderLabel, + String body, + @Nullable String meta, + boolean outgoing, + @Nullable String kindLabel + ) { + LinearLayout wrapper = new LinearLayout(context); + wrapper.setOrientation(LinearLayout.VERTICAL); + wrapper.setGravity(outgoing ? Gravity.END : Gravity.START); + LinearLayout.LayoutParams wrapperParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + wrapperParams.bottomMargin = dp(context, 12); + wrapper.setLayoutParams(wrapperParams); + + TextView metaView = new TextView(context); + String metaText = senderLabel; + if (!TextUtils.isEmpty(meta)) { + metaText = metaText + " · " + meta; + } + metaView.setText(metaText); + metaView.setTextSize(11); + metaView.setTextColor(context.getColor(R.color.boss_text_soft)); + metaView.setPadding(dp(context, 6), 0, dp(context, 6), dp(context, 4)); + wrapper.addView(metaView); + + LinearLayout bubble = new LinearLayout(context); + bubble.setOrientation(LinearLayout.VERTICAL); + bubble.setBackgroundResource(outgoing ? R.drawable.bg_message_outgoing : R.drawable.bg_message_incoming); + bubble.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12)); + int maxBubbleWidth = Math.round(context.getResources().getDisplayMetrics().widthPixels * 0.72f); + bubble.setMinimumWidth(dp(context, 84)); + + if (!TextUtils.isEmpty(kindLabel)) { + TextView kindView = new TextView(context); + kindView.setText(kindLabel); + kindView.setTextSize(11); + kindView.setTypeface(Typeface.DEFAULT_BOLD); + kindView.setTextColor(context.getColor(outgoing ? R.color.boss_surface : R.color.boss_text_muted)); + kindView.setPadding(0, 0, 0, dp(context, 6)); + bubble.addView(kindView); + } + + TextView bodyView = new TextView(context); + bodyView.setText(TextUtils.isEmpty(body) ? "(空消息)" : body); + bodyView.setTextSize(15); + bodyView.setLineSpacing(0f, 1.2f); + bodyView.setTextColor(context.getColor(outgoing ? R.color.boss_surface : R.color.boss_text_primary)); + bodyView.setMaxWidth(maxBubbleWidth); + bubble.addView(bodyView); + + wrapper.addView(bubble); + return wrapper; + } + + public static TextView buildMessagePlaceholder(Context context, String text) { + TextView placeholder = new TextView(context); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.topMargin = dp(context, 24); + placeholder.setLayoutParams(params); + placeholder.setGravity(Gravity.CENTER); + placeholder.setPadding(dp(context, 24), dp(context, 12), dp(context, 24), dp(context, 12)); + placeholder.setText(text); + placeholder.setTextSize(13); + placeholder.setTextColor(context.getColor(R.color.boss_text_soft)); + return placeholder; + } + public static Button buildPrimaryButton(Context context, String label) { Button button = new Button(context); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( 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 fb69a8a..64baa0d 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -2,15 +2,16 @@ package com.hyzq.boss; import android.content.Intent; import android.os.Bundle; -import android.view.ViewGroup; +import android.text.TextUtils; +import android.view.View; import android.widget.Button; +import android.widget.EditText; import android.widget.LinearLayout; +import android.widget.ScrollView; import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; import org.json.JSONArray; -import org.json.JSONException; import org.json.JSONObject; public class ProjectDetailActivity extends BossScreenActivity { @@ -19,14 +20,29 @@ public class ProjectDetailActivity extends BossScreenActivity { private String projectId; private String initialProjectName; + private LinearLayout quickActionsLayout; + private EditText composerInput; + private Button composerSendButton; + private ScrollView chatScrollView; + + @Override + protected int getLayoutResId() { + return R.layout.activity_project_chat; + } @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); initialProjectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME); + quickActionsLayout = findViewById(R.id.project_chat_quick_actions); + composerInput = findViewById(R.id.project_chat_input); + composerSendButton = findViewById(R.id.project_chat_send); + chatScrollView = findViewById(R.id.project_chat_scroll); + configureScreen(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情..."); - setHeaderAction("发消息", v -> chooseMessageKindAndSend()); + hideHeaderAction(); + composerSendButton.setOnClickListener(v -> sendTextMessageFromComposer()); reload(); } @@ -54,167 +70,92 @@ public class ProjectDetailActivity extends BossScreenActivity { }); } + @Override + protected void setRefreshing(boolean refreshing) { + super.setRefreshing(refreshing); + if (composerInput != null) { + composerInput.setEnabled(!refreshing); + } + if (composerSendButton != null) { + composerSendButton.setEnabled(!refreshing); + } + } + private void renderProject(JSONObject payload) { JSONObject project = payload.optJSONObject("project"); JSONArray devices = payload.optJSONArray("devices"); - JSONArray threadContexts = payload.optJSONArray("activeThreadContexts"); - JSONArray recentLogs = payload.optJSONArray("recentAppLogs"); String title = project != null ? project.optString("name", "项目详情") : "项目详情"; - String subtitle = "设备:" + joinDeviceNames(devices); - configureScreen(title, subtitle); + initialProjectName = title; + configureScreen(title, "设备:" + joinDeviceNames(devices)); + renderQuickActions(); replaceContent(); - appendContent(buildActionGrid()); - - JSONObject masterIdentity = payload.optJSONObject("masterIdentity"); - if (masterIdentity != null) { - String body = masterIdentity.optString("roleLabel", "主控") - + " · " + masterIdentity.optString("displayName", "-") - + (masterIdentity.optString("nodeLabel").isEmpty() ? "" : " · " + masterIdentity.optString("nodeLabel")) - + (masterIdentity.optString("model").isEmpty() ? "" : "\n模型 " + masterIdentity.optString("model")); - String meta = masterIdentity.optString("statusLabel", "") - + (masterIdentity.optString("lastSwitchedAt").isEmpty() ? "" : " · 最近切换 " + masterIdentity.optString("lastSwitchedAt")); - appendContent(BossUi.buildCard(this, "当前主控身份", body, meta)); - } - - appendContent(BossUi.buildCard( - this, - "主 Agent 调度结论", - payload.optString("masterContextStrategySummary", "暂无调度摘要。"), - "原生项目详情已接入 /api/v1/projects/{projectId}" - )); - - if (threadContexts != null && threadContexts.length() > 0) { - for (int i = 0; i < threadContexts.length(); i++) { - JSONObject thread = threadContexts.optJSONObject(i); - if (thread == null) continue; - JSONObject snapshot = thread.optJSONObject("snapshot"); - if (snapshot == null) continue; - String threadId = snapshot.optString("threadId"); - String body = snapshot.optString("summary", "暂无摘要"); - String meta = snapshot.optString("workerId", "-") - + " · " + snapshot.optString("nodeId", "-") - + " · " + snapshot.optInt("contextBudgetRemainingPct", 0) + "%" - + " · " + snapshot.optString("contextBudgetLevel", "safe"); - appendContent(BossUi.buildCard( - this, - snapshot.optString("title", "线程详情"), - body, - meta, - v -> openThread(threadId) - )); - } - } else { - appendContent(BossUi.buildEmptyCard(this, "当前项目还没有线程预算数据。")); - } - - if (recentLogs != null && recentLogs.length() > 0) { - for (int i = 0; i < recentLogs.length(); i++) { - JSONObject log = recentLogs.optJSONObject(i); - if (log == null) continue; - String body = log.optString("message", "无消息体"); - if (!log.optString("detail").isEmpty()) { - body = body + "\n" + log.optString("detail"); - } - String meta = log.optString("deviceId", "-") - + " · " + log.optString("category", "-") - + " · " + log.optString("createdAt", "-"); - appendContent(BossUi.buildCard(this, "实时 APP 日志", body, meta)); - } - } JSONArray messages = project == null ? null : project.optJSONArray("messages"); if (messages != null && messages.length() > 0) { for (int i = 0; i < messages.length(); i++) { JSONObject message = messages.optJSONObject(i); - if (message == null) continue; - String meta = message.optString("sentAt", "-") - + (message.optString("kind").isEmpty() ? "" : " · " + message.optString("kind")); - appendContent(BossUi.buildCard( + if (message == null) { + continue; + } + appendContent(BossUi.buildMessageBubble( this, message.optString("senderLabel", "消息"), message.optString("body", ""), - meta + formatMessageTime(message.optString("sentAt", "")), + isOutgoingMessage(message.optString("senderLabel", "")), + labelForMessageKind(message.optString("kind", "")) )); } + } else { + appendContent(BossUi.buildMessagePlaceholder(this, "还没有项目消息,先发一条开始对话。")); } - appendContent(BossUi.buildCard( - this, - "媒体与转发说明", - "语音、图片、视频与转发现在都通过原生入口触发,并写回现有 Boss 消息账本。", - "对象存储与真实媒体文件仍保持 MVP 占位。" - )); setRefreshing(false); + scrollChatToBottom(); } - private LinearLayout buildActionGrid() { - LinearLayout wrapper = new LinearLayout(this); - wrapper.setOrientation(LinearLayout.VERTICAL); - wrapper.setLayoutParams(new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - )); - - wrapper.addView(buildActionRow( - buildActionButton("发送消息", v -> chooseMessageKindAndSend()), - buildActionButton("项目目标", v -> openGoals()) - )); - wrapper.addView(buildActionRow( - buildActionButton("版本记录", v -> openVersions()), - buildActionButton("消息转发", v -> openForward()) - )); - return wrapper; + private void renderQuickActions() { + if (quickActionsLayout == null) { + return; + } + quickActionsLayout.removeAllViews(); + String[] actions = WechatSurfaceMapper.projectQuickActions(); + for (int i = 0; i < actions.length; i++) { + String action = actions[i]; + Button button = buildQuickActionButton(action, i == 0); + if ("项目目标".equals(action)) { + button.setOnClickListener(v -> openGoals()); + } else if ("版本记录".equals(action)) { + button.setOnClickListener(v -> openVersions()); + } + quickActionsLayout.addView(button); + } } - private LinearLayout buildActionRow(Button left, Button right) { - LinearLayout row = new LinearLayout(this); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ); - params.bottomMargin = BossUi.dp(this, 12); - row.setLayoutParams(params); - row.setOrientation(LinearLayout.HORIZONTAL); - - LinearLayout.LayoutParams childParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); - childParams.rightMargin = BossUi.dp(this, 6); - left.setLayoutParams(childParams); - - LinearLayout.LayoutParams rightParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); - rightParams.leftMargin = BossUi.dp(this, 6); - right.setLayoutParams(rightParams); - - row.addView(left); - row.addView(right); - return row; - } - - private Button buildActionButton(String label, android.view.View.OnClickListener listener) { - Button button = BossUi.buildPrimaryButton(this, label); - button.setOnClickListener(listener); + private Button buildQuickActionButton(String label, boolean highlight) { + Button button = new Button(this); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, BossUi.dp(this, 40), 1f); + if (quickActionsLayout.getChildCount() > 0) { + params.leftMargin = BossUi.dp(this, 8); + } + button.setLayoutParams(params); + button.setMinWidth(0); + button.setText(label); + button.setTextSize(14); + button.setAllCaps(false); + button.setPadding(BossUi.dp(this, 12), 0, BossUi.dp(this, 12), 0); + button.setBackgroundResource(highlight ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button); + button.setTextColor(getColor(highlight ? R.color.boss_surface : R.color.boss_text_primary)); return button; } - private void chooseMessageKindAndSend() { - final String[] labels = {"文本消息", "语音意图", "图片意图", "视频意图"}; - final String[] kinds = {"text", "voice_intent", "image_intent", "video_intent"}; - new AlertDialog.Builder(this) - .setTitle("选择消息类型") - .setItems(labels, (dialog, which) -> showSendDialog(kinds[which], labels[which])) - .setNegativeButton("取消", null) - .show(); - } - - private void showSendDialog(String kind, String label) { - final android.widget.EditText input = BossUi.buildInput(this, "请输入要发送给项目的内容", true); - new AlertDialog.Builder(this) - .setTitle("发送" + label) - .setView(input) - .setNegativeButton("取消", null) - .setPositiveButton("发送", (dialog, which) -> sendProjectMessage(kind, input.getText().toString().trim())) - .show(); + private void sendTextMessageFromComposer() { + if (composerInput == null) { + return; + } + sendProjectMessage("text", composerInput.getText().toString().trim()); } private void sendProjectMessage(String kind, String body) { @@ -226,9 +167,11 @@ public class ProjectDetailActivity extends BossScreenActivity { executor.execute(() -> { try { BossApiClient.ApiResponse response = apiClient.sendProjectMessage(projectId, body, kind); - if (!response.ok()) throw new IllegalStateException(response.message()); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } runOnUiThread(() -> { - setRefreshing(false); + composerInput.setText(""); showMessage("消息已发送"); reload(); }); @@ -255,20 +198,6 @@ public class ProjectDetailActivity extends BossScreenActivity { startActivity(intent); } - private void openForward() { - Intent intent = new Intent(this, ProjectForwardActivity.class); - intent.putExtra(ProjectForwardActivity.EXTRA_PROJECT_ID, projectId); - intent.putExtra(ProjectForwardActivity.EXTRA_PROJECT_NAME, initialProjectName); - startActivity(intent); - } - - private void openThread(String threadId) { - Intent intent = new Intent(this, ThreadDetailActivity.class); - intent.putExtra(ThreadDetailActivity.EXTRA_THREAD_ID, threadId); - intent.putExtra(ThreadDetailActivity.EXTRA_PROJECT_ID, projectId); - startActivity(intent); - } - private String joinDeviceNames(@Nullable JSONArray devices) { if (devices == null || devices.length() == 0) { return "未绑定设备"; @@ -276,10 +205,64 @@ public class ProjectDetailActivity extends BossScreenActivity { StringBuilder builder = new StringBuilder(); for (int i = 0; i < devices.length(); i++) { JSONObject device = devices.optJSONObject(i); - if (device == null) continue; - if (builder.length() > 0) builder.append(" / "); + if (device == null) { + continue; + } + if (builder.length() > 0) { + builder.append(" / "); + } builder.append(device.optString("name", device.optString("id", "设备"))); } return builder.length() == 0 ? "未绑定设备" : builder.toString(); } + + private void scrollChatToBottom() { + if (chatScrollView == null) { + return; + } + chatScrollView.post(() -> chatScrollView.fullScroll(View.FOCUS_DOWN)); + } + + private boolean isOutgoingMessage(String senderLabel) { + if (TextUtils.isEmpty(senderLabel)) { + return false; + } + return "你".equals(senderLabel) + || senderLabel.equals(apiClient.getDisplayName()) + || senderLabel.equals(apiClient.getAccountLabel()); + } + + private String formatMessageTime(String sentAt) { + if (TextUtils.isEmpty(sentAt)) { + return ""; + } + int timeSeparator = sentAt.indexOf('T'); + if (timeSeparator >= 0 && sentAt.length() >= timeSeparator + 6) { + return sentAt.substring(timeSeparator + 1, timeSeparator + 6); + } + int blankIndex = sentAt.indexOf(' '); + if (blankIndex >= 0 && sentAt.length() >= blankIndex + 6) { + return sentAt.substring(blankIndex + 1, blankIndex + 6); + } + return sentAt; + } + + @Nullable + private String labelForMessageKind(String kind) { + if (TextUtils.isEmpty(kind) || "text".equals(kind)) { + return null; + } + switch (kind) { + case "voice_intent": + return "语音"; + case "image_intent": + return "图片"; + case "video_intent": + return "视频"; + case "forward_notice": + return "转发"; + default: + return kind; + } + } } diff --git a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java index 6cb2fd2..a95321e 100644 --- a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java +++ b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java @@ -25,6 +25,12 @@ public final class WechatSurfaceMapper { "版本记录" ); + private static final List PROJECT_PRIMARY_SECTIONS = Arrays.asList( + "quick_actions", + "messages", + "composer" + ); + private WechatSurfaceMapper() { } @@ -58,6 +64,10 @@ public final class WechatSurfaceMapper { return PROJECT_QUICK_ACTIONS.toArray(new String[0]); } + public static String[] projectPrimarySections() { + return PROJECT_PRIMARY_SECTIONS.toArray(new String[0]); + } + private static String buildSubtitle(JSONObject source) { String statusValue = source.optString("status", ""); String status; diff --git a/android/app/src/main/res/drawable/bg_message_incoming.xml b/android/app/src/main/res/drawable/bg_message_incoming.xml new file mode 100644 index 0000000..7c72d25 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_message_incoming.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_message_outgoing.xml b/android/app/src/main/res/drawable/bg_message_outgoing.xml new file mode 100644 index 0000000..9b4baa5 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_message_outgoing.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/main/res/layout/activity_project_chat.xml b/android/app/src/main/res/layout/activity_project_chat.xml new file mode 100644 index 0000000..f3fee5f --- /dev/null +++ b/android/app/src/main/res/layout/activity_project_chat.xml @@ -0,0 +1,166 @@ + + + + + +