Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d6d6d7ff7 | ||
|
|
b3eb758093 | ||
|
|
26fe9679b6 | ||
|
|
836fe675d7 | ||
|
|
4909031290 | ||
|
|
839dda286b | ||
|
|
a10d51719a | ||
|
|
569bdf962e | ||
|
|
253fa5c592 | ||
|
|
2fdc112979 | ||
|
|
f204431d34 | ||
|
|
f5cc00b985 | ||
|
|
fd3ba44e47 | ||
|
|
d89f9ee584 | ||
|
|
9ba1d74b16 | ||
|
|
ed9b95298c | ||
|
|
ae36f08025 | ||
|
|
da70a1e334 | ||
|
|
b7b2f3f183 | ||
|
|
826f9352e9 | ||
|
|
237d22f9af | ||
|
|
088efddd8d | ||
|
|
dc915ac59c | ||
|
|
1685cedca9 | ||
|
|
7ddc1a07e6 | ||
|
|
af0b8e3693 | ||
|
|
a936271de9 | ||
|
|
bd26392536 | ||
|
|
69da5e7f58 | ||
|
|
2ee18bcb1e | ||
|
|
eb17b476eb | ||
|
|
9f183af0d7 | ||
|
|
f7bc965a86 | ||
|
|
a09bf356e7 | ||
|
|
e43be6e145 | ||
|
|
5c1ad78de7 | ||
|
|
44f10d8196 | ||
|
|
1e9b6d4fc2 | ||
|
|
c32c7051a7 | ||
|
|
f74ef81d8b | ||
|
|
80ae4d5342 | ||
|
|
42bf0eccbb | ||
|
|
2088fa1c91 | ||
|
|
9c4632a816 | ||
|
|
880d0471af | ||
|
|
5443f6437f | ||
|
|
df90d71e75 | ||
|
|
b61cb1514f | ||
|
|
aa1ee352b6 | ||
|
|
8d2ec0d5c9 | ||
|
|
4ae42b14d3 | ||
|
|
3f720b72cf | ||
|
|
0da314e13f | ||
|
|
376879bb74 | ||
|
|
473124cd98 | ||
|
|
600aa41b1f | ||
|
|
392b961fb6 | ||
|
|
82b556f0c0 | ||
|
|
1a66bc4a2b | ||
|
|
b958c37bf0 | ||
|
|
fcb0483901 | ||
|
|
bf12771908 | ||
|
|
0e2035a480 | ||
|
|
df25f2346e | ||
|
|
4fb1945bba | ||
|
|
cd74cae930 | ||
|
|
4eeafe9221 |
@@ -4,7 +4,7 @@
|
||||
|
||||
An independent Android companion app for Multica users.
|
||||
|
||||
[](https://github.com/park0er/multi-casual-android/releases/latest) [](https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/) [](https://multi-casual-android.pages.dev/)
|
||||
[](https://github.com/park0er/multi-casual-android/releases/latest) [](https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/) [](https://multi-casual-android.pages.dev/)
|
||||
|
||||
English | [简体中文](#简体中文)
|
||||
|
||||
@@ -51,8 +51,8 @@ You can also build the app without analytics keys. In that case, analytics provi
|
||||
For normal installation, download the signed release APK from GitHub Releases:
|
||||
|
||||
- Latest release: https://github.com/park0er/multi-casual-android/releases/latest
|
||||
- Current signed APK: `Multi-Casual-Android-v0.1.5.apk`
|
||||
- SHA256: `3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
|
||||
- Current signed APK: `Multi-Casual-Android-v0.1.3.apk`
|
||||
- SHA256: `317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4`
|
||||
|
||||
The debug APK produced by local development commands is not intended for public distribution.
|
||||
|
||||
@@ -166,8 +166,8 @@ Analytics 是 opt-in。App 会先请求用户同意,然后才会上报 analyti
|
||||
普通用户安装时,请从 GitHub Releases 下载签名 release APK:
|
||||
|
||||
- 最新 release:https://github.com/park0er/multi-casual-android/releases/latest
|
||||
- 当前签名 APK:`Multi-Casual-Android-v0.1.5.apk`
|
||||
- SHA256:`3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
|
||||
- 当前签名 APK:`Multi-Casual-Android-v0.1.3.apk`
|
||||
- SHA256:`317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4`
|
||||
|
||||
本地开发命令生成的 debug APK 不用于公开分发。
|
||||
|
||||
|
||||
+2
-2
@@ -51,8 +51,8 @@ android {
|
||||
applicationId "ai.multicasual.app"
|
||||
minSdk 24
|
||||
targetSdk 36
|
||||
versionCode 5
|
||||
versionName "0.1.4"
|
||||
versionCode 3
|
||||
versionName "0.1.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
||||
@@ -259,14 +259,19 @@ final class ApiClient {
|
||||
Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
|
||||
String status, String priority, Models.Assignee assignee, String dueDate,
|
||||
String parentIssueId) throws Exception {
|
||||
return createIssue(title, description, workspaceId, projectId, null, status, priority, assignee, dueDate, parentIssueId);
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("title", title);
|
||||
body.put("description", description == null || description.isEmpty() ? JSONObject.NULL : description);
|
||||
body.put("workspace_id", workspaceId);
|
||||
if (projectId != null) body.put("project_id", projectId);
|
||||
if (parentIssueId != null && !parentIssueId.isEmpty()) body.put("parent_issue_id", parentIssueId);
|
||||
if (status != null) body.put("status", status);
|
||||
if (priority != null) body.put("priority", priority);
|
||||
if (dueDate != null && !dueDate.isEmpty()) body.put("due_date", dueDate); else body.put("due_date", JSONObject.NULL);
|
||||
if (assignee != null && assignee.id != null) {
|
||||
body.put("assignee_id", assignee.id);
|
||||
body.put("assignee_type", assignee.type);
|
||||
}
|
||||
|
||||
Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
|
||||
String teamId, String status, String priority, Models.Assignee assignee, String dueDate,
|
||||
String parentIssueId) throws Exception {
|
||||
JSONObject body = IssuePayloads.createIssue(title, description, workspaceId, projectId, teamId, status, priority,
|
||||
assignee, dueDate, parentIssueId);
|
||||
return new Models.Issue(requestObject("POST", "/api/issues", query(null, "workspace_id", workspaceId), body));
|
||||
}
|
||||
|
||||
@@ -521,31 +526,6 @@ final class ApiClient {
|
||||
return new Models.Attachment(json);
|
||||
}
|
||||
|
||||
String downloadAttachmentText(String url) throws Exception {
|
||||
if (url == null || url.trim().isEmpty()) return "";
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url.trim()).openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(30000);
|
||||
conn.setRequestProperty("Accept", "text/markdown,text/html,application/xhtml+xml,application/xml,text/xml,text/*,*/*;q=0.8");
|
||||
conn.setRequestProperty("X-Client-Platform", "android");
|
||||
conn.setRequestProperty("X-Client-Version", "debug");
|
||||
String token = authStore.token();
|
||||
if (token != null && !token.isEmpty() && url.startsWith(BuildConfig.MULTICA_API_BASE_URL)) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||
}
|
||||
String cookieHeader = authStore.cloudFrontCookieHeader();
|
||||
if (cookieHeader != null && !cookieHeader.isEmpty() && url.contains("static.multica.ai")) {
|
||||
conn.setRequestProperty("Cookie", cookieHeader);
|
||||
}
|
||||
int code = conn.getResponseCode();
|
||||
saveResponseCookies(conn);
|
||||
InputStream stream = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream();
|
||||
String text = readTextPreservingLines(stream);
|
||||
if (code < 200 || code >= 300) throw new ApiException(code, text);
|
||||
return text;
|
||||
}
|
||||
|
||||
void deleteAttachment(String workspaceId, String attachmentId) throws Exception {
|
||||
requestObject("DELETE", "/api/attachments/" + encPath(attachmentId),
|
||||
query(null, "workspace_id", workspaceId), null);
|
||||
@@ -776,7 +756,7 @@ final class ApiClient {
|
||||
List<Models.Squad> squads(String workspaceId, boolean includeArchived) throws Exception {
|
||||
JSONObject query = query(null, "workspace_id", workspaceId);
|
||||
if (includeArchived) query.put("include_archived", "true");
|
||||
JSONObject json = requestObject("GET", "/api/squads", query, null, workspaceIdHeaders(workspaceId));
|
||||
JSONObject json = requestObject("GET", "/api/squads", query, null);
|
||||
return parseArray(extractArray(json, "squads"), Models.Squad::new);
|
||||
}
|
||||
|
||||
@@ -1531,17 +1511,6 @@ final class ApiClient {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String readTextPreservingLines(InputStream stream) throws Exception {
|
||||
if (stream == null) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
char[] buffer = new char[4096];
|
||||
try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
|
||||
int count;
|
||||
while ((count = reader.read(buffer)) != -1) sb.append(buffer, 0, count);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String queryString(JSONObject query) throws Exception {
|
||||
if (query == null || query.length() == 0) return "";
|
||||
StringBuilder sb = new StringBuilder("?");
|
||||
@@ -1593,14 +1562,6 @@ final class ApiClient {
|
||||
throw new IllegalArgumentException("workspace_id is required. Select a workspace first.");
|
||||
}
|
||||
|
||||
static Map<String, String> workspaceIdHeaders(String workspaceId) {
|
||||
String id = workspaceId == null ? "" : workspaceId.trim();
|
||||
if (!id.isEmpty()) {
|
||||
return Collections.singletonMap("X-Workspace-ID", id);
|
||||
}
|
||||
throw new IllegalArgumentException("workspace_id is required. Select a workspace first.");
|
||||
}
|
||||
|
||||
private static boolean isBlankQueryValue(Object value) {
|
||||
if (value == null || value == JSONObject.NULL) return true;
|
||||
return String.valueOf(value).trim().isEmpty();
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
final class AttachmentPreview {
|
||||
static final String BASE_URL = "https://attachment-preview.local/";
|
||||
|
||||
enum Kind {
|
||||
MARKDOWN,
|
||||
HTML,
|
||||
XML,
|
||||
DOWNLOAD
|
||||
}
|
||||
|
||||
private AttachmentPreview() {}
|
||||
|
||||
static Kind kindFor(String contentType, String filename) {
|
||||
String type = mediaType(contentType);
|
||||
if (isMarkdownType(type)) return Kind.MARKDOWN;
|
||||
if (isHtmlType(type)) return Kind.HTML;
|
||||
if (isXmlType(type)) return Kind.XML;
|
||||
|
||||
String name = filename == null ? "" : filename.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (name.endsWith(".md") || name.endsWith(".markdown")) return Kind.MARKDOWN;
|
||||
if (name.endsWith(".html") || name.endsWith(".htm")) return Kind.HTML;
|
||||
if (name.endsWith(".xml")) return Kind.XML;
|
||||
return Kind.DOWNLOAD;
|
||||
}
|
||||
|
||||
static boolean isLocalPreviewUrl(String url) {
|
||||
if (url == null || url.trim().isEmpty()) return false;
|
||||
try {
|
||||
URI uri = URI.create(url.trim());
|
||||
String scheme = uri.getScheme();
|
||||
String host = uri.getHost();
|
||||
return "https".equalsIgnoreCase(scheme) && "attachment-preview.local".equalsIgnoreCase(host);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isWebViewInternalDocumentUrl(String url) {
|
||||
if (url == null) return false;
|
||||
String value = url.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
return value.equals("about:blank") || value.startsWith("data:text/html");
|
||||
}
|
||||
|
||||
static String xmlPreviewDocument(String xml) {
|
||||
return "<!doctype html><html><head><meta charset=\"utf-8\">"
|
||||
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
|
||||
+ "<style>body{margin:0;padding:16px;background:#fff;color:#111;font:14px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;}pre{white-space:pre-wrap;word-break:break-word;margin:0;}</style>"
|
||||
+ "</head><body><pre>"
|
||||
+ escapeHtml(xml == null ? "" : xml)
|
||||
+ "</pre></body></html>";
|
||||
}
|
||||
|
||||
private static String mediaType(String contentType) {
|
||||
if (contentType == null) return "";
|
||||
int semicolon = contentType.indexOf(';');
|
||||
String value = semicolon >= 0 ? contentType.substring(0, semicolon) : contentType;
|
||||
return value.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
private static boolean isMarkdownType(String type) {
|
||||
return "text/markdown".equals(type)
|
||||
|| "text/x-markdown".equals(type)
|
||||
|| "application/markdown".equals(type)
|
||||
|| "application/x-markdown".equals(type);
|
||||
}
|
||||
|
||||
private static boolean isHtmlType(String type) {
|
||||
return "text/html".equals(type) || "application/xhtml+xml".equals(type);
|
||||
}
|
||||
|
||||
private static boolean isXmlType(String type) {
|
||||
return "text/xml".equals(type) || "application/xml".equals(type) || type.endsWith("+xml");
|
||||
}
|
||||
|
||||
private static String escapeHtml(String value) {
|
||||
return value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package ai.multica.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
@@ -13,11 +12,6 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.LinearLayout
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -36,7 +30,6 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -295,22 +288,6 @@ class ComposePilotActivity : ComponentActivity() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (intent.getBooleanExtra("preview_attachment_markdown", false)) {
|
||||
setContent {
|
||||
MulticaTheme {
|
||||
PilotAttachmentMarkdownPreviewFixture()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (intent.getBooleanExtra("preview_attachment_html", false)) {
|
||||
setContent {
|
||||
MulticaTheme {
|
||||
PilotAttachmentHtmlPreviewFixture()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (intent.getBooleanExtra("preview_no_workspace_onboarding", false)) {
|
||||
val previewCreationSuccess = intent.getBooleanExtra("preview_no_workspace_success", false)
|
||||
val previewUser = Models.User(JSONObject()
|
||||
@@ -451,56 +428,12 @@ private data class PilotSkillDetailData(
|
||||
val files: List<Models.SkillFile>,
|
||||
)
|
||||
|
||||
private data class PilotMentionOption(
|
||||
private data class PilotAssigneeOption(
|
||||
val id: String?,
|
||||
val type: String?,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
private fun pilotMentionOptions(
|
||||
includeEmpty: Boolean,
|
||||
emptyLabel: String,
|
||||
currentUser: Models.User?,
|
||||
members: List<Models.Member>,
|
||||
agents: List<Models.Agent>,
|
||||
squads: List<Models.Squad>,
|
||||
query: String = "",
|
||||
): List<PilotMentionOption> {
|
||||
val normalizedQuery = query.trim().lowercase()
|
||||
return Models.issueAssignees(includeEmpty, emptyLabel, currentUser, members, agents, squads)
|
||||
.filter { option ->
|
||||
normalizedQuery.isBlank() ||
|
||||
option.name.lowercase().contains(normalizedQuery) ||
|
||||
option.id.orEmpty().lowercase().contains(normalizedQuery)
|
||||
}
|
||||
.map { PilotMentionOption(it.id, it.type, it.name) }
|
||||
}
|
||||
|
||||
private fun pilotIssueFormAssigneeOptions(
|
||||
emptyLabel: String,
|
||||
currentUser: Models.User?,
|
||||
members: List<Models.Member>,
|
||||
agents: List<Models.Agent>,
|
||||
squads: List<Models.Squad>,
|
||||
): List<PilotMentionOption> {
|
||||
return Models.issueFormAssigneeOptions(emptyLabel, currentUser, members, agents, squads)
|
||||
.map { PilotMentionOption(it.id, it.type, it.name) }
|
||||
}
|
||||
|
||||
private fun pilotMentionTypeLabel(type: String?, zh: Boolean): String = when (type) {
|
||||
"member" -> if (zh) "人" else "Person"
|
||||
"agent" -> "Agent"
|
||||
"squad" -> if (zh) "小队" else "Squad"
|
||||
else -> if (zh) "无" else "None"
|
||||
}
|
||||
|
||||
private fun pilotMentionTypeColor(type: String?): Color = when (type) {
|
||||
"member" -> Color(0xFF16A34A)
|
||||
"agent" -> MulticaColors.Accent
|
||||
"squad" -> MulticaColors.Warning
|
||||
else -> MulticaColors.Muted
|
||||
}
|
||||
|
||||
private val PILOT_REACTION_EMOJIS = listOf("👍", "❤️", "👀", "🎉")
|
||||
private const val PILOT_INBOX_PAGE_SIZE = 20
|
||||
|
||||
@@ -2067,7 +2000,6 @@ private fun PilotTabContent(
|
||||
workspaceId = session.workspace.id,
|
||||
workspace = session.workspace,
|
||||
projects = state.projects,
|
||||
currentUser = session.user,
|
||||
zh = zh,
|
||||
onProjectClick = onProjectClick,
|
||||
onRefresh = onRetry,
|
||||
@@ -6841,8 +6773,6 @@ private fun IssueFormMenuRow(
|
||||
items = options.map { option ->
|
||||
MulticaCupertinoActionSheetItem(
|
||||
title = option.label,
|
||||
badgeText = option.badgeText,
|
||||
badgeColor = option.badgeColor,
|
||||
contentDescription = "$contentDescription ${option.label}",
|
||||
selected = option.selected,
|
||||
onClick = option.onClick,
|
||||
@@ -6856,8 +6786,6 @@ private fun IssueFormMenuRow(
|
||||
|
||||
private data class IssueFormMenuOption(
|
||||
val label: String,
|
||||
val badgeText: String? = null,
|
||||
val badgeColor: Color = MulticaColors.Accent,
|
||||
val selected: Boolean,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
@@ -6897,7 +6825,7 @@ private fun PilotIssueForm(
|
||||
projects = api.projects(session.workspace.id, 500, 0).items,
|
||||
members = api.members(session.workspace.id),
|
||||
agents = api.agents(session.workspace.id),
|
||||
squads = api.squads(session.workspace.id),
|
||||
squads = runCatching { api.squads(session.workspace.id) }.getOrElse { emptyList() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6912,13 +6840,14 @@ private fun PilotIssueForm(
|
||||
)
|
||||
else -> {
|
||||
val options = loaded.getOrThrow()
|
||||
val assignees = pilotIssueFormAssigneeOptions(
|
||||
val assignees = Models.issueAssignees(
|
||||
true,
|
||||
if (zh) "未分配" else "Unassigned",
|
||||
session.user,
|
||||
options.members,
|
||||
options.agents,
|
||||
options.squads,
|
||||
)
|
||||
).map { PilotAssigneeOption(it.id, it.type, it.name) }
|
||||
var title by remember(issue?.id) { mutableStateOf(issue?.title.orEmpty()) }
|
||||
var description by remember(issue?.id) { mutableStateOf(issue?.description.orEmpty()) }
|
||||
var dueDate by remember(issue?.id) { mutableStateOf(issue?.dueDate.orEmpty()) }
|
||||
@@ -7018,7 +6947,6 @@ private fun PilotIssueForm(
|
||||
val status = Models.STATUS_VALUES[statusIndex]
|
||||
val priority = Models.PRIORITY_VALUES[priorityIndex]
|
||||
val selectedAssignee = assignees[assigneeIndex]
|
||||
val teamId = if (!editing && selectedAssignee.type == "squad") selectedAssignee.id else null
|
||||
val assignee = if (selectedAssignee.id == null) null else {
|
||||
Models.Assignee(selectedAssignee.id, selectedAssignee.type, selectedAssignee.name)
|
||||
}
|
||||
@@ -7028,7 +6956,7 @@ private fun PilotIssueForm(
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val saved = if (issue == null) {
|
||||
api.createIssue(titleText, description, session.workspace.id, projectId, teamId, status, priority, assignee, dueDateText, parentIssueId)
|
||||
api.createIssue(titleText, description, session.workspace.id, projectId, status, priority, assignee, dueDateText, parentIssueId)
|
||||
} else {
|
||||
api.updateIssue(issue, titleText, description, projectId, status, priority, assignee, dueDateText)
|
||||
}
|
||||
@@ -7265,8 +7193,6 @@ private fun PilotIssueForm(
|
||||
options = assignees.mapIndexed { index, assignee ->
|
||||
IssueFormMenuOption(
|
||||
label = assignee.name,
|
||||
badgeText = pilotMentionTypeLabel(assignee.type, zh),
|
||||
badgeColor = pilotMentionTypeColor(assignee.type),
|
||||
selected = assigneeIndex == index,
|
||||
onClick = { assigneeIndex = index },
|
||||
)
|
||||
@@ -9383,283 +9309,6 @@ private fun IssueAttachmentsWebPanel(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PilotAttachmentPreview(
|
||||
api: ApiClient,
|
||||
attachment: Models.Attachment,
|
||||
zh: Boolean,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val kind = remember(attachment.contentType, attachment.filename) {
|
||||
AttachmentPreview.kindFor(attachment.contentType, attachment.filename)
|
||||
}
|
||||
val target = remember(attachment.downloadUrl, attachment.url) {
|
||||
absoluteAttachmentUrl(clean(attachment.downloadUrl).ifBlank { clean(attachment.url) })
|
||||
}
|
||||
var state by remember(attachment.id) { mutableStateOf<Result<String>?>(null) }
|
||||
var refresh by remember(attachment.id) { mutableIntStateOf(0) }
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
LaunchedEffect(attachment.id, target, refresh) {
|
||||
state = null
|
||||
state = withContext(Dispatchers.IO) {
|
||||
runCatching { api.downloadAttachmentText(target) }
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.semantics { contentDescription = "Attachment Preview Sheet ${attachment.id}" },
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.32f))
|
||||
.clickable(onClick = onBack),
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.97f)
|
||||
.statusBarsPadding(),
|
||||
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
||||
color = MulticaColors.Background,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 12.dp, top = 4.dp, end = 12.dp, bottom = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(28.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(36.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(42.dp)
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MulticaColors.Border),
|
||||
)
|
||||
MulticaIconPillButton(
|
||||
icon = Icons.Outlined.Close,
|
||||
contentDescription = if (zh) "关闭附件预览" else "Close attachment preview",
|
||||
onClick = onBack,
|
||||
tone = MulticaButtonTone.Ghost,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
when {
|
||||
target.isBlank() -> MulticaErrorState(
|
||||
message = if (zh) "附件没有可预览链接" else "Attachment has no preview URL",
|
||||
onRetry = onBack,
|
||||
fullScreen = false,
|
||||
)
|
||||
state == null -> Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CupertinoActivityIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
state?.isFailure == true -> MulticaErrorState(
|
||||
message = "${if (zh) "附件加载失败" else "Attachment load failed"}\n${state?.exceptionOrNull()?.message.orEmpty()}",
|
||||
onRetry = { refresh++ },
|
||||
fullScreen = false,
|
||||
)
|
||||
else -> {
|
||||
val body = state?.getOrNull().orEmpty()
|
||||
when (kind) {
|
||||
AttachmentPreview.Kind.MARKDOWN -> AttachmentMarkdownPreview(body)
|
||||
AttachmentPreview.Kind.HTML -> AttachmentHtmlPreview(body, context)
|
||||
AttachmentPreview.Kind.XML -> AttachmentHtmlPreview(AttachmentPreview.xmlPreviewDocument(body), context)
|
||||
AttachmentPreview.Kind.DOWNLOAD -> MulticaErrorState(
|
||||
message = if (zh) "该附件类型不支持内置预览" else "This attachment type does not support built-in preview",
|
||||
onRetry = onBack,
|
||||
fullScreen = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentMarkdownPreview(markdown: String) {
|
||||
SelectionContainer {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
factory = { context ->
|
||||
LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0, 0, 0, 24)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.removeAllViews()
|
||||
MarkdownRenderer.render(
|
||||
view.context,
|
||||
view,
|
||||
markdown,
|
||||
MulticaColors.Text.toArgb(),
|
||||
MulticaColors.Muted.toArgb(),
|
||||
MulticaColors.Border.toArgb(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
private fun ColumnScope.AttachmentHtmlPreview(html: String, context: Context) {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
factory = { viewContext ->
|
||||
WebView(viewContext).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = false
|
||||
settings.databaseEnabled = false
|
||||
settings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||
settings.allowFileAccess = false
|
||||
settings.allowContentAccess = false
|
||||
settings.allowFileAccessFromFileURLs = false
|
||||
settings.allowUniversalAccessFromFileURLs = false
|
||||
clearCache(true)
|
||||
clearHistory()
|
||||
webViewClient = AttachmentPreviewWebViewClient(context)
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
webView.loadDataWithBaseURL(AttachmentPreview.BASE_URL, html, "text/html", "UTF-8", null)
|
||||
},
|
||||
onRelease = { webView ->
|
||||
webView.stopLoading()
|
||||
webView.clearHistory()
|
||||
webView.clearCache(true)
|
||||
webView.destroy()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private class AttachmentPreviewWebViewClient(
|
||||
private val context: Context,
|
||||
) : WebViewClient() {
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
return handleNavigation(url)
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||
return handleNavigation(request.url?.toString().orEmpty())
|
||||
}
|
||||
|
||||
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun handleNavigation(url: String): Boolean {
|
||||
if (AttachmentPreview.isWebViewInternalDocumentUrl(url)) return false
|
||||
if (AttachmentPreview.isLocalPreviewUrl(url)) return false
|
||||
openExternalUrl(context, url)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PilotAttachmentMarkdownPreviewFixture() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MulticaColors.Background)
|
||||
.statusBarsPadding()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
PilotPageHeader(title = "Markdown Attachment Preview")
|
||||
AttachmentMarkdownPreview(
|
||||
"""
|
||||
# Markdown Attachment
|
||||
|
||||
This attachment renders **inside Android** with the existing Markdown renderer.
|
||||
|
||||
| Feature | Status |
|
||||
| --- | --- |
|
||||
| Tables | Ready |
|
||||
| Links | [Multica](https://app.multica.ai) |
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PilotAttachmentHtmlPreviewFixture() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MulticaColors.Background)
|
||||
.statusBarsPadding()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
PilotPageHeader(title = "HTML Attachment Preview")
|
||||
AttachmentHtmlPreview(
|
||||
"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { font: 16px -apple-system, BlinkMacSystemFont, sans-serif; padding: 16px; color: #111; }
|
||||
button { border: 1px solid #d1d5db; border-radius: 999px; padding: 10px 14px; background: white; }
|
||||
button.active { background: #0A58FF; color: white; }
|
||||
.panel { display: none; margin-top: 16px; padding: 16px; border: 1px solid #e5e7eb; border-radius: 16px; }
|
||||
.panel.active { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>HTML Attachment</h1>
|
||||
<button id="tab-one" class="active" onclick="showTab('one')">Tab One</button>
|
||||
<button id="tab-two" onclick="showTab('two')">Tab Two</button>
|
||||
<a href="#local-anchor" id="anchor-link">Jump to anchor</a>
|
||||
<a href="https://example.com/" id="external-link">External link</a>
|
||||
<section id="one" class="panel active">First tab content</section>
|
||||
<section id="two" class="panel">Second tab content</section>
|
||||
<p id="local-anchor">Local anchor target</p>
|
||||
<script>
|
||||
function showTab(id) {
|
||||
document.querySelectorAll('button').forEach(function(button) { button.classList.remove('active'); });
|
||||
document.querySelectorAll('.panel').forEach(function(panel) { panel.classList.remove('active'); });
|
||||
document.getElementById('tab-' + id).classList.add('active');
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent(),
|
||||
LocalContext.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldCollapseIssueComment(content: String): Boolean {
|
||||
if (content.contains("```")) return false
|
||||
if (content.lines().any { it.trim().startsWith("|") && it.trim().endsWith("|") }) return false
|
||||
@@ -9719,7 +9368,6 @@ private fun PilotIssueDetail(
|
||||
var uploadingAttachment by remember(issueId) { mutableStateOf(false) }
|
||||
var deletingAttachmentId by remember(issueId) { mutableStateOf<String?>(null) }
|
||||
var attachmentMessage by remember(issueId) { mutableStateOf<String?>(null) }
|
||||
var previewAttachment by remember(issueId) { mutableStateOf<Models.Attachment?>(null) }
|
||||
var confirmingDeleteIssue by remember(issueId) { mutableStateOf(false) }
|
||||
var deletingIssue by remember(issueId) { mutableStateOf(false) }
|
||||
var issueMutationMessage by remember(issueId) { mutableStateOf<String?>(null) }
|
||||
@@ -9756,6 +9404,7 @@ private fun PilotIssueDetail(
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LaunchedEffect(issueId, workspaceId, highlightCommentId, detailRefresh) {
|
||||
state = null
|
||||
val coreResult = withContext(Dispatchers.IO) {
|
||||
@@ -10001,15 +9650,11 @@ private fun PilotIssueDetail(
|
||||
}
|
||||
|
||||
fun openAttachment(attachment: Models.Attachment) {
|
||||
val target = absoluteAttachmentUrl(clean(attachment.downloadUrl).ifBlank { clean(attachment.url) })
|
||||
val target = clean(attachment.downloadUrl).ifBlank { clean(attachment.url) }
|
||||
if (target.isBlank()) {
|
||||
attachmentMessage = if (zh) "附件没有可打开的链接" else "Attachment has no openable URL"
|
||||
return
|
||||
}
|
||||
if (AttachmentPreview.kindFor(attachment.contentType, attachment.filename) != AttachmentPreview.Kind.DOWNLOAD) {
|
||||
previewAttachment = attachment
|
||||
return
|
||||
}
|
||||
val result = runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(target))
|
||||
@@ -11115,14 +10760,6 @@ if (replyingThread != null) {
|
||||
},
|
||||
)
|
||||
}
|
||||
previewAttachment?.let { attachment ->
|
||||
PilotAttachmentPreview(
|
||||
api = api,
|
||||
attachment = attachment,
|
||||
zh = zh,
|
||||
onBack = { previewAttachment = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11196,7 +10833,34 @@ private fun IssueCommentReplyBox(
|
||||
val focusManager = LocalFocusManager.current
|
||||
val replyActionModifier = Modifier.size(42.dp)
|
||||
val mentionItems = remember(agents, members, squads, mentionQuery) {
|
||||
pilotMentionOptions(false, "", null, members, agents.filter { it.archivedAt.isBlank() }, squads, mentionQuery)
|
||||
val query = mentionQuery.trim().lowercase()
|
||||
val agentItems = agents
|
||||
.filter { it.archivedAt.isBlank() }
|
||||
.filter { agent ->
|
||||
query.isBlank() ||
|
||||
agent.name.lowercase().contains(query) ||
|
||||
agent.description.lowercase().contains(query) ||
|
||||
agent.id.lowercase().contains(query)
|
||||
}
|
||||
.sortedBy { it.name.lowercase() }
|
||||
.map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "agent") }
|
||||
val memberItems = members
|
||||
.filter { member ->
|
||||
query.isBlank() ||
|
||||
member.displayName.lowercase().contains(query) ||
|
||||
member.email.lowercase().contains(query)
|
||||
}
|
||||
.sortedBy { it.displayName.lowercase() }
|
||||
.map { Triple(it.id, it.displayName, "member") }
|
||||
val squadItems = squads
|
||||
.filter { squad ->
|
||||
query.isBlank() ||
|
||||
squad.name.lowercase().contains(query) ||
|
||||
squad.description.lowercase().contains(query)
|
||||
}
|
||||
.sortedBy { it.name.lowercase() }
|
||||
.map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "squad") }
|
||||
agentItems + memberItems + squadItems
|
||||
}
|
||||
LaunchedEffect(parentId) {
|
||||
delay(180)
|
||||
@@ -11338,15 +11002,12 @@ Column(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
mentionItems.forEach { item ->
|
||||
mentionItems.forEach { (id, name, type) ->
|
||||
IssueMentionUnifiedRow(
|
||||
name = item.name,
|
||||
mentionType = item.type.orEmpty(),
|
||||
zh = zh,
|
||||
contentDescription = "Issue Comment Reply Mention ${item.type} Row ${item.id}",
|
||||
name = name,
|
||||
mentionType = type,
|
||||
contentDescription = "Issue Comment Reply Mention $type Row $id",
|
||||
onClick = {
|
||||
val name = item.name
|
||||
val id = item.id.orEmpty()
|
||||
val insertion = AgentMentionMarkdown.displayText(name, id)
|
||||
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
|
||||
onDraftChange(draft + separator + insertion + " ")
|
||||
@@ -11437,7 +11098,6 @@ private fun IssueMentionAgentWebRow(
|
||||
private fun IssueMentionUnifiedRow(
|
||||
name: String,
|
||||
mentionType: String,
|
||||
zh: Boolean,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
@@ -11446,13 +11106,21 @@ private fun IssueMentionUnifiedRow(
|
||||
"squad" -> name.take(1).uppercase().ifBlank { "S" }
|
||||
else -> name.take(1).uppercase().ifBlank { "A" }
|
||||
}
|
||||
val iconColor = pilotMentionTypeColor(mentionType)
|
||||
val iconColor = when (mentionType) {
|
||||
"member" -> MulticaColors.Text
|
||||
"squad" -> MulticaColors.Accent
|
||||
else -> MulticaColors.Accent
|
||||
}
|
||||
val iconBg = when (mentionType) {
|
||||
"member" -> MulticaColors.AccentSoft.copy(alpha = 0.12f)
|
||||
"squad" -> MulticaColors.AccentSoft.copy(alpha = 0.36f)
|
||||
else -> MulticaColors.AccentSoft.copy(alpha = 0.24f)
|
||||
}
|
||||
val typeLabel = pilotMentionTypeLabel(mentionType, zh)
|
||||
val typeLabel = when (mentionType) {
|
||||
"member" -> "member"
|
||||
"squad" -> "squad"
|
||||
else -> "agent"
|
||||
}
|
||||
val mentionMarkdown = when (mentionType) {
|
||||
"member" -> AgentMentionMarkdown.memberMarkdown(name, "")
|
||||
"squad" -> AgentMentionMarkdown.squadMarkdown(name, "")
|
||||
@@ -11549,7 +11217,34 @@ private fun IssueCommentInputBar(
|
||||
configuration.screenHeightDp,
|
||||
).dp
|
||||
val mentionItems = remember(agents, members, squads, mentionQuery) {
|
||||
pilotMentionOptions(false, "", null, members, agents.filter { it.archivedAt.isBlank() }, squads, mentionQuery)
|
||||
val query = mentionQuery.trim().lowercase()
|
||||
val agentItems = agents
|
||||
.filter { it.archivedAt.isBlank() }
|
||||
.filter { agent ->
|
||||
query.isBlank() ||
|
||||
agent.name.lowercase().contains(query) ||
|
||||
agent.description.lowercase().contains(query) ||
|
||||
agent.id.lowercase().contains(query)
|
||||
}
|
||||
.sortedBy { it.name.lowercase() }
|
||||
.map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "agent") }
|
||||
val memberItems = members
|
||||
.filter { member ->
|
||||
query.isBlank() ||
|
||||
member.displayName.lowercase().contains(query) ||
|
||||
member.email.lowercase().contains(query)
|
||||
}
|
||||
.sortedBy { it.displayName.lowercase() }
|
||||
.map { Triple(it.id, it.displayName, "member") }
|
||||
val squadItems = squads
|
||||
.filter { squad ->
|
||||
query.isBlank() ||
|
||||
squad.name.lowercase().contains(query) ||
|
||||
squad.description.lowercase().contains(query)
|
||||
}
|
||||
.sortedBy { it.name.lowercase() }
|
||||
.map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "squad") }
|
||||
agentItems + memberItems + squadItems
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -11715,15 +11410,12 @@ Column(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
mentionItems.forEach { item ->
|
||||
mentionItems.forEach { (id, name, type) ->
|
||||
IssueMentionUnifiedRow(
|
||||
name = item.name,
|
||||
mentionType = item.type.orEmpty(),
|
||||
zh = zh,
|
||||
contentDescription = "Issue Comment Mention ${item.type} Row ${item.id}",
|
||||
name = name,
|
||||
mentionType = type,
|
||||
contentDescription = "Issue Comment Mention $type Row $id",
|
||||
onClick = {
|
||||
val name = item.name
|
||||
val id = item.id.orEmpty()
|
||||
val insertion = AgentMentionMarkdown.displayText(name, id)
|
||||
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
|
||||
onDraftChange(draft + separator + insertion + " ")
|
||||
@@ -12364,15 +12056,6 @@ private fun absoluteMarkdownImageUrl(url: String): String {
|
||||
}
|
||||
}
|
||||
|
||||
private fun absoluteAttachmentUrl(url: String): String {
|
||||
val value = clean(url)
|
||||
return if (value.startsWith("/")) {
|
||||
BuildConfig.MULTICA_API_BASE_URL.trimEnd('/') + value
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
private fun openExternalUrl(context: Context, url: String) {
|
||||
val target = clean(url)
|
||||
if (target.isBlank()) return
|
||||
@@ -13295,7 +12978,6 @@ private fun PilotProjects(
|
||||
workspaceId: String,
|
||||
workspace: Models.Workspace,
|
||||
projects: List<Models.Project>,
|
||||
currentUser: Models.User?,
|
||||
zh: Boolean,
|
||||
onProjectClick: (Models.Project) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
@@ -13318,7 +13000,6 @@ private fun PilotProjects(
|
||||
var resourceUrlsText by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf("") }
|
||||
var projectMembers by remember(workspaceId) { mutableStateOf<List<Models.Member>>(emptyList()) }
|
||||
var projectAgents by remember(workspaceId) { mutableStateOf<List<Models.Agent>>(emptyList()) }
|
||||
var projectSquads by remember(workspaceId) { mutableStateOf<List<Models.Squad>>(emptyList()) }
|
||||
var loadingProjectOptions by remember(workspaceId) { mutableStateOf(false) }
|
||||
var saving by remember(workspaceId) { mutableStateOf(false) }
|
||||
var pendingDelete by remember(workspaceId) { mutableStateOf<Models.Project?>(null) }
|
||||
@@ -13418,7 +13099,7 @@ private fun PilotProjects(
|
||||
}
|
||||
|
||||
LaunchedEffect(formOpen, workspaceId) {
|
||||
if (!formOpen || projectMembers.isNotEmpty() || projectAgents.isNotEmpty() || projectSquads.isNotEmpty() || loadingProjectOptions) return@LaunchedEffect
|
||||
if (!formOpen || projectMembers.isNotEmpty() || projectAgents.isNotEmpty() || loadingProjectOptions) return@LaunchedEffect
|
||||
loadingProjectOptions = true
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { api.members(workspaceId) to api.agents(workspaceId) }
|
||||
@@ -13428,26 +13109,15 @@ private fun PilotProjects(
|
||||
}.onFailure {
|
||||
resultText = "${if (zh) "项目选项加载失败" else "Project options failed"}: ${it.message ?: it.toString()}"
|
||||
}
|
||||
val squads = withContext(Dispatchers.IO) { runCatching { api.squads(workspaceId) }.getOrDefault(emptyList()) }
|
||||
projectSquads = squads
|
||||
loadingProjectOptions = false
|
||||
}
|
||||
|
||||
val projectLeadOptions = remember(currentUser, projectMembers, projectAgents, projectSquads, zh) {
|
||||
pilotIssueFormAssigneeOptions(
|
||||
if (zh) "无负责人" else "No Lead",
|
||||
currentUser,
|
||||
projectMembers,
|
||||
projectAgents,
|
||||
projectSquads,
|
||||
)
|
||||
}
|
||||
|
||||
val selectedLeadLabel = remember(leadType, leadId, projectLeadOptions, zh) {
|
||||
projectLeadOptions.firstOrNull { option ->
|
||||
option.id == leadId && (option.type == leadType || leadType.isBlank())
|
||||
}?.name ?: when (leadType) {
|
||||
"squad", "agent", "member" -> Models.shortId(leadId)
|
||||
val selectedLeadLabel = remember(leadType, leadId, projectMembers, projectAgents, zh) {
|
||||
when (leadType) {
|
||||
"member" -> projectMembers.firstOrNull { it.userId == leadId || it.id == leadId }?.let { member ->
|
||||
member.displayName.ifBlank { member.email }
|
||||
} ?: Models.shortId(leadId)
|
||||
"agent" -> projectAgents.firstOrNull { it.id == leadId }?.name ?: Models.shortId(leadId)
|
||||
else -> if (zh) "无负责人" else "No Lead"
|
||||
}
|
||||
}
|
||||
@@ -13597,24 +13267,39 @@ private fun PilotProjects(
|
||||
eyebrow = if (zh) "负责人" else "Lead",
|
||||
title = selectedLeadLabel,
|
||||
subtitle = if (loadingProjectOptions) {
|
||||
if (zh) "正在加载人、Agent 和小队" else "Loading people, agents, and squads"
|
||||
if (zh) "正在加载成员和 Agent" else "Loading members and agents"
|
||||
} else {
|
||||
null
|
||||
},
|
||||
contentDescription = "Project Lead Picker",
|
||||
options = projectLeadOptions.map { lead ->
|
||||
options = listOf(
|
||||
IssueFormMenuOption(
|
||||
label = lead.name,
|
||||
badgeText = pilotMentionTypeLabel(lead.type, zh),
|
||||
badgeColor = pilotMentionTypeColor(lead.type),
|
||||
selected = if (lead.id == null) {
|
||||
leadType.isBlank() || leadId.isBlank()
|
||||
} else {
|
||||
leadType == lead.type && leadId == lead.id
|
||||
},
|
||||
label = if (zh) "无负责人" else "No Lead",
|
||||
selected = leadType.isBlank() || leadId.isBlank(),
|
||||
onClick = {
|
||||
leadType = lead.type.orEmpty()
|
||||
leadId = lead.id.orEmpty()
|
||||
leadType = ""
|
||||
leadId = ""
|
||||
},
|
||||
)
|
||||
) + projectMembers.map { member ->
|
||||
val assigneeId = member.userId.ifBlank { member.id }
|
||||
IssueFormMenuOption(
|
||||
label = listOf(member.displayName.ifBlank { member.email }, if (zh) "成员" else "Member")
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" · "),
|
||||
selected = leadType == "member" && (leadId == assigneeId || leadId == member.id),
|
||||
onClick = {
|
||||
leadType = "member"
|
||||
leadId = assigneeId
|
||||
},
|
||||
)
|
||||
} + projectAgents.map { agent ->
|
||||
IssueFormMenuOption(
|
||||
label = listOf(agent.name, "Agent").filter { it.isNotBlank() }.joinToString(" · "),
|
||||
selected = leadType == "agent" && leadId == agent.id,
|
||||
onClick = {
|
||||
leadType = "agent"
|
||||
leadId = agent.id
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class IssuePayloads {
|
||||
private IssuePayloads() {
|
||||
}
|
||||
|
||||
static JSONObject createIssue(String title, String description, String workspaceId, String projectId,
|
||||
String teamId, String status, String priority, Models.Assignee assignee,
|
||||
String dueDate, String parentIssueId) throws Exception {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("title", title);
|
||||
body.put("description", description == null || description.isEmpty() ? JSONObject.NULL : description);
|
||||
body.put("workspace_id", workspaceId);
|
||||
if (projectId != null) body.put("project_id", projectId);
|
||||
if (teamId != null && !teamId.trim().isEmpty()) body.put("team_id", teamId.trim());
|
||||
if (parentIssueId != null && !parentIssueId.isEmpty()) body.put("parent_issue_id", parentIssueId);
|
||||
if (status != null) body.put("status", status);
|
||||
if (priority != null) body.put("priority", priority);
|
||||
if (dueDate != null && !dueDate.isEmpty()) body.put("due_date", dueDate); else body.put("due_date", JSONObject.NULL);
|
||||
if (assignee != null && assignee.id != null) {
|
||||
body.put("assignee_id", assignee.id);
|
||||
body.put("assignee_type", assignee.type);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -810,7 +810,6 @@ public final class MainActivity extends Activity {
|
||||
String statusValue = Models.STATUS_VALUES[Math.max(0, status.getSelectedItemPosition())];
|
||||
String priorityValue = Models.PRIORITY_VALUES[Math.max(0, priority.getSelectedItemPosition())];
|
||||
Models.Assignee selectedAssignee = assigneeAt(assignee.getSelectedItemPosition(), true);
|
||||
String teamId = selectedAssignee != null && "squad".equals(selectedAssignee.type) ? selectedAssignee.id : null;
|
||||
if (demoMode) {
|
||||
upsertDemoIssue(
|
||||
editing ? issue.id : "demo-issue-" + (demoIssues.size() + 1),
|
||||
@@ -829,7 +828,7 @@ public final class MainActivity extends Activity {
|
||||
return api.updateIssue(issue, titleText, desc.getText().toString(), projectId, statusValue, priorityValue, selectedAssignee);
|
||||
}
|
||||
if (currentWorkspace == null) throw new IllegalStateException(t("workspaceRequired"));
|
||||
return api.createIssue(titleText, desc.getText().toString(), currentWorkspace.id, projectId, teamId, statusValue, priorityValue, selectedAssignee, null, null);
|
||||
return api.createIssue(titleText, desc.getText().toString(), currentWorkspace.id, projectId, statusValue, priorityValue, selectedAssignee);
|
||||
}, saved -> afterSave.run(), error -> toast(t("saveFailed") + ": " + error.getMessage()));
|
||||
}).show();
|
||||
}
|
||||
@@ -1862,9 +1861,7 @@ public final class MainActivity extends Activity {
|
||||
private List<Models.Assignee> assignees(boolean includeEmpty) {
|
||||
if (memberCache.isEmpty()) memberCache = safeMembers();
|
||||
if (agentCache.isEmpty()) agentCache = safeAgents();
|
||||
if (squadCache.isEmpty()) squadCache = safeSquads();
|
||||
if (includeEmpty) return Models.issueFormAssigneeOptions(t("unassigned"), currentUser, memberCache, agentCache, squadCache);
|
||||
return Models.issueAssignees(false, t("unassigned"), currentUser, memberCache, agentCache, squadCache);
|
||||
return Models.issueAssignees(includeEmpty, t("unassigned"), currentUser, memberCache, agentCache, squadCache);
|
||||
}
|
||||
|
||||
private String assigneeName(String id, String type) {
|
||||
|
||||
@@ -230,31 +230,6 @@ final class Models {
|
||||
return list;
|
||||
}
|
||||
|
||||
static List<Assignee> issueFormAssigneeOptions(String emptyLabel, User currentUser,
|
||||
List<Member> members, List<Agent> agents, List<Squad> squads) {
|
||||
List<Assignee> list = new ArrayList<>();
|
||||
list.add(new Assignee(null, null, emptyLabel));
|
||||
for (Squad squad : squads) {
|
||||
if (squad.id == null || squad.id.isEmpty()) continue;
|
||||
list.add(new Assignee(squad.id, "squad", squad.name));
|
||||
}
|
||||
for (Agent agent : agents) {
|
||||
if (agent.id == null || agent.id.isEmpty()) continue;
|
||||
list.add(new Assignee(agent.id, "agent", agent.name));
|
||||
}
|
||||
List<String> seenMemberIds = new ArrayList<>();
|
||||
if (currentUser != null && currentUser.id != null && !currentUser.id.isEmpty()) {
|
||||
list.add(new Assignee(currentUser.id, "member", currentUser.name));
|
||||
seenMemberIds.add(currentUser.id);
|
||||
}
|
||||
for (Member member : members) {
|
||||
if (member.id == null || member.id.isEmpty() || seenMemberIds.contains(member.id)) continue;
|
||||
list.add(new Assignee(member.id, "member", member.displayName));
|
||||
seenMemberIds.add(member.id);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
static final class Page<T> {
|
||||
final List<T> items;
|
||||
final boolean hasMore;
|
||||
|
||||
@@ -1021,8 +1021,6 @@ data class MulticaCupertinoActionSheetItem(
|
||||
val title: String,
|
||||
val subtitle: String? = null,
|
||||
val icon: ImageVector? = null,
|
||||
val badgeText: String? = null,
|
||||
val badgeColor: Color = MulticaColors.Accent,
|
||||
val contentDescription: String? = null,
|
||||
val selected: Boolean = false,
|
||||
val destructive: Boolean = false,
|
||||
@@ -1058,10 +1056,6 @@ fun MulticaCupertinoActionSheet(
|
||||
)
|
||||
},
|
||||
message = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (!message.isNullOrBlank()) {
|
||||
Text(
|
||||
text = message,
|
||||
@@ -1069,59 +1063,22 @@ fun MulticaCupertinoActionSheet(
|
||||
color = MulticaColors.TextTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 420.dp),
|
||||
) {
|
||||
items(items.size) { index ->
|
||||
val item = items[index]
|
||||
MulticaCupertinoActionSheetItemContent(
|
||||
item = item,
|
||||
},
|
||||
buttons = {
|
||||
items.forEach { item ->
|
||||
action(
|
||||
onClick = {
|
||||
if (!item.enabled) return@MulticaCupertinoActionSheetItemContent
|
||||
performMulticaTapFeedback(haptic)
|
||||
item.onClick()
|
||||
onDismissRequest()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
buttons = {
|
||||
if (!cancelText.isNullOrBlank()) {
|
||||
action(
|
||||
onClick = {
|
||||
performMulticaTapFeedback(haptic)
|
||||
onDismissRequest()
|
||||
},
|
||||
style = AlertActionStyle.Cancel,
|
||||
enabled = true,
|
||||
) {
|
||||
Text(
|
||||
text = cancelText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold),
|
||||
color = MulticaColors.Accent,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MulticaCupertinoActionSheetItemContent(
|
||||
item: MulticaCupertinoActionSheetItem,
|
||||
onClick: () -> Unit,
|
||||
style = if (item.destructive) AlertActionStyle.Destructive else AlertActionStyle.Default,
|
||||
enabled = item.enabled,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = item.enabled, onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 11.dp)
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = "Multica Cupertino Action Sheet ${item.contentDescription ?: item.title}"
|
||||
},
|
||||
@@ -1136,24 +1093,6 @@ private fun MulticaCupertinoActionSheetItemContent(
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
if (!item.badgeText.isNullOrBlank()) {
|
||||
Text(
|
||||
text = item.badgeText,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(item.badgeColor.copy(alpha = 0.16f))
|
||||
.border(1.dp, item.badgeColor.copy(alpha = 0.32f), RoundedCornerShape(999.dp))
|
||||
.padding(horizontal = 7.dp, vertical = 3.dp),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = 10.sp,
|
||||
lineHeight = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = item.badgeColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = item.title,
|
||||
@@ -1188,6 +1127,26 @@ private fun MulticaCupertinoActionSheetItemContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cancelText.isNullOrBlank()) {
|
||||
action(
|
||||
onClick = {
|
||||
performMulticaTapFeedback(haptic)
|
||||
onDismissRequest()
|
||||
},
|
||||
style = AlertActionStyle.Cancel,
|
||||
enabled = true,
|
||||
) {
|
||||
Text(
|
||||
text = cancelText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold),
|
||||
color = MulticaColors.Accent,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalCupertinoApi::class)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<main>
|
||||
<h1>Multi-Casual Android</h1>
|
||||
<p>Main site is published at <a href="https://multi-casual-android.pages.dev">https://multi-casual-android.pages.dev</a> and <a href="https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/">https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/</a>.</p>
|
||||
<p>You can also install the signed APK directly from <a href="https://github.com/park0er/multi-casual-android/releases/download/v0.1.4/Multi-Casual-Android-v0.1.4.apk">GitHub Releases</a>.</p>
|
||||
<p>You can also install the signed APK directly from <a href="https://github.com/park0er/multi-casual-android/releases/download/v0.1.2/Multi-Casual-Android-v0.1.2.apk">GitHub Releases</a>.</p>
|
||||
</main>
|
||||
<footer>
|
||||
<p>Site placeholder published from the public Android source repository.</p>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"Name":"multi-casual-china","ProjectId":"pages-wu0vu5wirtvg"}
|
||||
+3
-3
@@ -19,9 +19,9 @@ import promoVideo from "./assets/video/multica-android-promo-draft.mp4";
|
||||
|
||||
const release = {
|
||||
packageName: "ai.multicasual.app",
|
||||
sha256: "3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49",
|
||||
sha256: "317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4",
|
||||
githubUrl: "https://github.com/park0er/multi-casual-android",
|
||||
releasesUrl: "https://github.com/park0er/multi-casual-android/releases/download/v0.1.5/Multi-Casual-Android-v0.1.5.apk",
|
||||
releasesUrl: "https://github.com/park0er/multi-casual-android/releases/download/v0.1.3/Multi-Casual-Android-v0.1.3.apk",
|
||||
iosUrl: "https://github.com/park0er/multi-casual-ios-app",
|
||||
multicaUrl: "https://multica.ai",
|
||||
apiUrl: "https://api.multica.ai",
|
||||
@@ -640,7 +640,7 @@ export default function App() {
|
||||
<code>{release.sha256}</code>
|
||||
</div>
|
||||
</div>
|
||||
<pre><code>{`adb install -r Multi-Casual-Android-v0.1.5.apk`}</code></pre>
|
||||
<pre><code>{`adb install -r Multi-Casual-Android-v0.1.3.apk`}</code></pre>
|
||||
<div className="cta-row compact">
|
||||
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.download.latest}</a>
|
||||
<a className="button secondary" href={release.githubUrl}>{t.download.source}</a>
|
||||
|
||||
Reference in New Issue
Block a user