Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62f87bbc38 | ||
|
|
3788d8e592 | ||
|
|
50ec6e80e1 | ||
|
|
c55230deed | ||
|
|
202076a1b0 | ||
|
|
ffb4ce404f | ||
|
|
47bb6088df | ||
|
|
43e84df960 | ||
|
|
af1ea96832 | ||
|
|
604a08ebc4 | ||
|
|
45412e5fdd | ||
|
|
014e4e7761 | ||
|
|
186cec5470 | ||
|
|
ee033e5044 | ||
|
|
f913e4d956 | ||
|
|
55882171a7 | ||
|
|
e99195ca69 | ||
|
|
19d941fa0f | ||
|
|
eea07f74f4 | ||
|
|
14297b83da | ||
|
|
57b30bcd31 | ||
|
|
2b760752bd | ||
|
|
bf53296006 | ||
|
|
dbf7c75581 | ||
|
|
41f09fe6d1 | ||
|
|
ad8bd857be | ||
|
|
84a2f84355 | ||
|
|
60100eb132 | ||
|
|
15130bb222 | ||
|
|
5b1263a178 | ||
|
|
0eb7d17a3a | ||
|
|
73e70ca4f1 | ||
|
|
ff8af0b81b | ||
|
|
b16a85aca8 | ||
|
|
ead6275f8b | ||
|
|
c2500283d5 | ||
|
|
52bb4cf5bf | ||
|
|
5316098a0c | ||
|
|
35b1639241 | ||
|
|
06ab3f74eb | ||
|
|
6df3b0d53e | ||
|
|
8a0b1703c1 | ||
|
|
b3469a5e5d | ||
|
|
2d69bb55f7 | ||
|
|
2dbf159b1b | ||
|
|
1b56284479 | ||
|
|
b04552f6fe | ||
|
|
a1cfdb1367 | ||
|
|
ac3e668019 | ||
|
|
6e514521df | ||
|
|
28ecdcab8a | ||
|
|
f005de88ca | ||
|
|
d8337a5b4c | ||
|
|
01acd39ac9 | ||
|
|
cfa608211c | ||
|
|
b6d61135ca | ||
|
|
ff09efe3a1 | ||
|
|
d9faa6c0c6 | ||
|
|
10e55d9477 | ||
|
|
3cd9a6bd53 | ||
|
|
0ccfc653da | ||
|
|
f77fa6fa0d | ||
|
|
dc7dfd2fba | ||
|
|
b435a9bf7d | ||
|
|
36377f5047 | ||
|
|
271918a8b1 | ||
|
|
a0051182e9 | ||
|
|
a29e6512d2 | ||
|
|
e07e59df4e | ||
|
|
5ca191a872 | ||
|
|
412a298268 | ||
|
|
4a0c8c8b8b |
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
An independent Android companion app for Multica users.
|
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 | [简体中文](#简体中文)
|
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:
|
For normal installation, download the signed release APK from GitHub Releases:
|
||||||
|
|
||||||
- Latest release: https://github.com/park0er/multi-casual-android/releases/latest
|
- Latest release: https://github.com/park0er/multi-casual-android/releases/latest
|
||||||
- Current signed APK: `Multi-Casual-Android-v0.1.3.apk`
|
- Current signed APK: `Multi-Casual-Android-v0.1.5.apk`
|
||||||
- SHA256: `317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4`
|
- SHA256: `3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
|
||||||
|
|
||||||
The debug APK produced by local development commands is not intended for public distribution.
|
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:
|
普通用户安装时,请从 GitHub Releases 下载签名 release APK:
|
||||||
|
|
||||||
- 最新 release:https://github.com/park0er/multi-casual-android/releases/latest
|
- 最新 release:https://github.com/park0er/multi-casual-android/releases/latest
|
||||||
- 当前签名 APK:`Multi-Casual-Android-v0.1.3.apk`
|
- 当前签名 APK:`Multi-Casual-Android-v0.1.5.apk`
|
||||||
- SHA256:`317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4`
|
- SHA256:`3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
|
||||||
|
|
||||||
本地开发命令生成的 debug APK 不用于公开分发。
|
本地开发命令生成的 debug APK 不用于公开分发。
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -51,8 +51,8 @@ android {
|
|||||||
applicationId "ai.multicasual.app"
|
applicationId "ai.multicasual.app"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 3
|
versionCode 5
|
||||||
versionName "0.1.2"
|
versionName "0.1.4"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -259,19 +259,14 @@ final class ApiClient {
|
|||||||
Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
|
Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
|
||||||
String status, String priority, Models.Assignee assignee, String dueDate,
|
String status, String priority, Models.Assignee assignee, String dueDate,
|
||||||
String parentIssueId) throws Exception {
|
String parentIssueId) throws Exception {
|
||||||
JSONObject body = new JSONObject();
|
return createIssue(title, description, workspaceId, projectId, null, status, priority, assignee, dueDate, parentIssueId);
|
||||||
body.put("title", title);
|
}
|
||||||
body.put("description", description == null || description.isEmpty() ? JSONObject.NULL : description);
|
|
||||||
body.put("workspace_id", workspaceId);
|
Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
|
||||||
if (projectId != null) body.put("project_id", projectId);
|
String teamId, String status, String priority, Models.Assignee assignee, String dueDate,
|
||||||
if (parentIssueId != null && !parentIssueId.isEmpty()) body.put("parent_issue_id", parentIssueId);
|
String parentIssueId) throws Exception {
|
||||||
if (status != null) body.put("status", status);
|
JSONObject body = IssuePayloads.createIssue(title, description, workspaceId, projectId, teamId, status, priority,
|
||||||
if (priority != null) body.put("priority", priority);
|
assignee, dueDate, parentIssueId);
|
||||||
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 new Models.Issue(requestObject("POST", "/api/issues", query(null, "workspace_id", workspaceId), body));
|
return new Models.Issue(requestObject("POST", "/api/issues", query(null, "workspace_id", workspaceId), body));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +521,31 @@ final class ApiClient {
|
|||||||
return new Models.Attachment(json);
|
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 {
|
void deleteAttachment(String workspaceId, String attachmentId) throws Exception {
|
||||||
requestObject("DELETE", "/api/attachments/" + encPath(attachmentId),
|
requestObject("DELETE", "/api/attachments/" + encPath(attachmentId),
|
||||||
query(null, "workspace_id", workspaceId), null);
|
query(null, "workspace_id", workspaceId), null);
|
||||||
@@ -756,7 +776,7 @@ final class ApiClient {
|
|||||||
List<Models.Squad> squads(String workspaceId, boolean includeArchived) throws Exception {
|
List<Models.Squad> squads(String workspaceId, boolean includeArchived) throws Exception {
|
||||||
JSONObject query = query(null, "workspace_id", workspaceId);
|
JSONObject query = query(null, "workspace_id", workspaceId);
|
||||||
if (includeArchived) query.put("include_archived", "true");
|
if (includeArchived) query.put("include_archived", "true");
|
||||||
JSONObject json = requestObject("GET", "/api/squads", query, null);
|
JSONObject json = requestObject("GET", "/api/squads", query, null, workspaceIdHeaders(workspaceId));
|
||||||
return parseArray(extractArray(json, "squads"), Models.Squad::new);
|
return parseArray(extractArray(json, "squads"), Models.Squad::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1511,6 +1531,17 @@ final class ApiClient {
|
|||||||
return sb.toString();
|
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 {
|
private static String queryString(JSONObject query) throws Exception {
|
||||||
if (query == null || query.length() == 0) return "";
|
if (query == null || query.length() == 0) return "";
|
||||||
StringBuilder sb = new StringBuilder("?");
|
StringBuilder sb = new StringBuilder("?");
|
||||||
@@ -1562,6 +1593,14 @@ final class ApiClient {
|
|||||||
throw new IllegalArgumentException("workspace_id is required. Select a workspace first.");
|
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) {
|
private static boolean isBlankQueryValue(Object value) {
|
||||||
if (value == null || value == JSONObject.NULL) return true;
|
if (value == null || value == JSONObject.NULL) return true;
|
||||||
return String.valueOf(value).trim().isEmpty();
|
return String.valueOf(value).trim().isEmpty();
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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,6 +1,7 @@
|
|||||||
package ai.multica.app
|
package ai.multica.app
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -12,6 +13,11 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.OpenableColumns
|
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 android.widget.LinearLayout
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
@@ -30,6 +36,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
@@ -288,6 +295,22 @@ class ComposePilotActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
return
|
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)) {
|
if (intent.getBooleanExtra("preview_no_workspace_onboarding", false)) {
|
||||||
val previewCreationSuccess = intent.getBooleanExtra("preview_no_workspace_success", false)
|
val previewCreationSuccess = intent.getBooleanExtra("preview_no_workspace_success", false)
|
||||||
val previewUser = Models.User(JSONObject()
|
val previewUser = Models.User(JSONObject()
|
||||||
@@ -428,12 +451,56 @@ private data class PilotSkillDetailData(
|
|||||||
val files: List<Models.SkillFile>,
|
val files: List<Models.SkillFile>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class PilotAssigneeOption(
|
private data class PilotMentionOption(
|
||||||
val id: String?,
|
val id: String?,
|
||||||
val type: String?,
|
val type: String?,
|
||||||
val name: 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 val PILOT_REACTION_EMOJIS = listOf("👍", "❤️", "👀", "🎉")
|
||||||
private const val PILOT_INBOX_PAGE_SIZE = 20
|
private const val PILOT_INBOX_PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -2000,6 +2067,7 @@ private fun PilotTabContent(
|
|||||||
workspaceId = session.workspace.id,
|
workspaceId = session.workspace.id,
|
||||||
workspace = session.workspace,
|
workspace = session.workspace,
|
||||||
projects = state.projects,
|
projects = state.projects,
|
||||||
|
currentUser = session.user,
|
||||||
zh = zh,
|
zh = zh,
|
||||||
onProjectClick = onProjectClick,
|
onProjectClick = onProjectClick,
|
||||||
onRefresh = onRetry,
|
onRefresh = onRetry,
|
||||||
@@ -6773,6 +6841,8 @@ private fun IssueFormMenuRow(
|
|||||||
items = options.map { option ->
|
items = options.map { option ->
|
||||||
MulticaCupertinoActionSheetItem(
|
MulticaCupertinoActionSheetItem(
|
||||||
title = option.label,
|
title = option.label,
|
||||||
|
badgeText = option.badgeText,
|
||||||
|
badgeColor = option.badgeColor,
|
||||||
contentDescription = "$contentDescription ${option.label}",
|
contentDescription = "$contentDescription ${option.label}",
|
||||||
selected = option.selected,
|
selected = option.selected,
|
||||||
onClick = option.onClick,
|
onClick = option.onClick,
|
||||||
@@ -6786,6 +6856,8 @@ private fun IssueFormMenuRow(
|
|||||||
|
|
||||||
private data class IssueFormMenuOption(
|
private data class IssueFormMenuOption(
|
||||||
val label: String,
|
val label: String,
|
||||||
|
val badgeText: String? = null,
|
||||||
|
val badgeColor: Color = MulticaColors.Accent,
|
||||||
val selected: Boolean,
|
val selected: Boolean,
|
||||||
val onClick: () -> Unit,
|
val onClick: () -> Unit,
|
||||||
)
|
)
|
||||||
@@ -6825,7 +6897,7 @@ private fun PilotIssueForm(
|
|||||||
projects = api.projects(session.workspace.id, 500, 0).items,
|
projects = api.projects(session.workspace.id, 500, 0).items,
|
||||||
members = api.members(session.workspace.id),
|
members = api.members(session.workspace.id),
|
||||||
agents = api.agents(session.workspace.id),
|
agents = api.agents(session.workspace.id),
|
||||||
squads = runCatching { api.squads(session.workspace.id) }.getOrElse { emptyList() },
|
squads = api.squads(session.workspace.id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6840,14 +6912,13 @@ private fun PilotIssueForm(
|
|||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
val options = loaded.getOrThrow()
|
val options = loaded.getOrThrow()
|
||||||
val assignees = Models.issueAssignees(
|
val assignees = pilotIssueFormAssigneeOptions(
|
||||||
true,
|
|
||||||
if (zh) "未分配" else "Unassigned",
|
if (zh) "未分配" else "Unassigned",
|
||||||
session.user,
|
session.user,
|
||||||
options.members,
|
options.members,
|
||||||
options.agents,
|
options.agents,
|
||||||
options.squads,
|
options.squads,
|
||||||
).map { PilotAssigneeOption(it.id, it.type, it.name) }
|
)
|
||||||
var title by remember(issue?.id) { mutableStateOf(issue?.title.orEmpty()) }
|
var title by remember(issue?.id) { mutableStateOf(issue?.title.orEmpty()) }
|
||||||
var description by remember(issue?.id) { mutableStateOf(issue?.description.orEmpty()) }
|
var description by remember(issue?.id) { mutableStateOf(issue?.description.orEmpty()) }
|
||||||
var dueDate by remember(issue?.id) { mutableStateOf(issue?.dueDate.orEmpty()) }
|
var dueDate by remember(issue?.id) { mutableStateOf(issue?.dueDate.orEmpty()) }
|
||||||
@@ -6947,6 +7018,7 @@ private fun PilotIssueForm(
|
|||||||
val status = Models.STATUS_VALUES[statusIndex]
|
val status = Models.STATUS_VALUES[statusIndex]
|
||||||
val priority = Models.PRIORITY_VALUES[priorityIndex]
|
val priority = Models.PRIORITY_VALUES[priorityIndex]
|
||||||
val selectedAssignee = assignees[assigneeIndex]
|
val selectedAssignee = assignees[assigneeIndex]
|
||||||
|
val teamId = if (!editing && selectedAssignee.type == "squad") selectedAssignee.id else null
|
||||||
val assignee = if (selectedAssignee.id == null) null else {
|
val assignee = if (selectedAssignee.id == null) null else {
|
||||||
Models.Assignee(selectedAssignee.id, selectedAssignee.type, selectedAssignee.name)
|
Models.Assignee(selectedAssignee.id, selectedAssignee.type, selectedAssignee.name)
|
||||||
}
|
}
|
||||||
@@ -6956,7 +7028,7 @@ private fun PilotIssueForm(
|
|||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val saved = if (issue == null) {
|
val saved = if (issue == null) {
|
||||||
api.createIssue(titleText, description, session.workspace.id, projectId, status, priority, assignee, dueDateText, parentIssueId)
|
api.createIssue(titleText, description, session.workspace.id, projectId, teamId, status, priority, assignee, dueDateText, parentIssueId)
|
||||||
} else {
|
} else {
|
||||||
api.updateIssue(issue, titleText, description, projectId, status, priority, assignee, dueDateText)
|
api.updateIssue(issue, titleText, description, projectId, status, priority, assignee, dueDateText)
|
||||||
}
|
}
|
||||||
@@ -7193,6 +7265,8 @@ private fun PilotIssueForm(
|
|||||||
options = assignees.mapIndexed { index, assignee ->
|
options = assignees.mapIndexed { index, assignee ->
|
||||||
IssueFormMenuOption(
|
IssueFormMenuOption(
|
||||||
label = assignee.name,
|
label = assignee.name,
|
||||||
|
badgeText = pilotMentionTypeLabel(assignee.type, zh),
|
||||||
|
badgeColor = pilotMentionTypeColor(assignee.type),
|
||||||
selected = assigneeIndex == index,
|
selected = assigneeIndex == index,
|
||||||
onClick = { assigneeIndex = index },
|
onClick = { assigneeIndex = index },
|
||||||
)
|
)
|
||||||
@@ -9309,6 +9383,283 @@ 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 {
|
private fun shouldCollapseIssueComment(content: String): Boolean {
|
||||||
if (content.contains("```")) return false
|
if (content.contains("```")) return false
|
||||||
if (content.lines().any { it.trim().startsWith("|") && it.trim().endsWith("|") }) return false
|
if (content.lines().any { it.trim().startsWith("|") && it.trim().endsWith("|") }) return false
|
||||||
@@ -9368,6 +9719,7 @@ private fun PilotIssueDetail(
|
|||||||
var uploadingAttachment by remember(issueId) { mutableStateOf(false) }
|
var uploadingAttachment by remember(issueId) { mutableStateOf(false) }
|
||||||
var deletingAttachmentId by remember(issueId) { mutableStateOf<String?>(null) }
|
var deletingAttachmentId by remember(issueId) { mutableStateOf<String?>(null) }
|
||||||
var attachmentMessage 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 confirmingDeleteIssue by remember(issueId) { mutableStateOf(false) }
|
||||||
var deletingIssue by remember(issueId) { mutableStateOf(false) }
|
var deletingIssue by remember(issueId) { mutableStateOf(false) }
|
||||||
var issueMutationMessage by remember(issueId) { mutableStateOf<String?>(null) }
|
var issueMutationMessage by remember(issueId) { mutableStateOf<String?>(null) }
|
||||||
@@ -9404,7 +9756,6 @@ private fun PilotIssueDetail(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(issueId, workspaceId, highlightCommentId, detailRefresh) {
|
LaunchedEffect(issueId, workspaceId, highlightCommentId, detailRefresh) {
|
||||||
state = null
|
state = null
|
||||||
val coreResult = withContext(Dispatchers.IO) {
|
val coreResult = withContext(Dispatchers.IO) {
|
||||||
@@ -9650,11 +10001,15 @@ private fun PilotIssueDetail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun openAttachment(attachment: Models.Attachment) {
|
fun openAttachment(attachment: Models.Attachment) {
|
||||||
val target = clean(attachment.downloadUrl).ifBlank { clean(attachment.url) }
|
val target = absoluteAttachmentUrl(clean(attachment.downloadUrl).ifBlank { clean(attachment.url) })
|
||||||
if (target.isBlank()) {
|
if (target.isBlank()) {
|
||||||
attachmentMessage = if (zh) "附件没有可打开的链接" else "Attachment has no openable URL"
|
attachmentMessage = if (zh) "附件没有可打开的链接" else "Attachment has no openable URL"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (AttachmentPreview.kindFor(attachment.contentType, attachment.filename) != AttachmentPreview.Kind.DOWNLOAD) {
|
||||||
|
previewAttachment = attachment
|
||||||
|
return
|
||||||
|
}
|
||||||
val result = runCatching {
|
val result = runCatching {
|
||||||
context.startActivity(
|
context.startActivity(
|
||||||
Intent(Intent.ACTION_VIEW, Uri.parse(target))
|
Intent(Intent.ACTION_VIEW, Uri.parse(target))
|
||||||
@@ -10760,6 +11115,14 @@ if (replyingThread != null) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
previewAttachment?.let { attachment ->
|
||||||
|
PilotAttachmentPreview(
|
||||||
|
api = api,
|
||||||
|
attachment = attachment,
|
||||||
|
zh = zh,
|
||||||
|
onBack = { previewAttachment = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10833,34 +11196,7 @@ private fun IssueCommentReplyBox(
|
|||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val replyActionModifier = Modifier.size(42.dp)
|
val replyActionModifier = Modifier.size(42.dp)
|
||||||
val mentionItems = remember(agents, members, squads, mentionQuery) {
|
val mentionItems = remember(agents, members, squads, mentionQuery) {
|
||||||
val query = mentionQuery.trim().lowercase()
|
pilotMentionOptions(false, "", null, members, agents.filter { it.archivedAt.isBlank() }, squads, mentionQuery)
|
||||||
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) {
|
LaunchedEffect(parentId) {
|
||||||
delay(180)
|
delay(180)
|
||||||
@@ -11002,12 +11338,15 @@ Column(
|
|||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
mentionItems.forEach { (id, name, type) ->
|
mentionItems.forEach { item ->
|
||||||
IssueMentionUnifiedRow(
|
IssueMentionUnifiedRow(
|
||||||
name = name,
|
name = item.name,
|
||||||
mentionType = type,
|
mentionType = item.type.orEmpty(),
|
||||||
contentDescription = "Issue Comment Reply Mention $type Row $id",
|
zh = zh,
|
||||||
|
contentDescription = "Issue Comment Reply Mention ${item.type} Row ${item.id}",
|
||||||
onClick = {
|
onClick = {
|
||||||
|
val name = item.name
|
||||||
|
val id = item.id.orEmpty()
|
||||||
val insertion = AgentMentionMarkdown.displayText(name, id)
|
val insertion = AgentMentionMarkdown.displayText(name, id)
|
||||||
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
|
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
|
||||||
onDraftChange(draft + separator + insertion + " ")
|
onDraftChange(draft + separator + insertion + " ")
|
||||||
@@ -11098,6 +11437,7 @@ private fun IssueMentionAgentWebRow(
|
|||||||
private fun IssueMentionUnifiedRow(
|
private fun IssueMentionUnifiedRow(
|
||||||
name: String,
|
name: String,
|
||||||
mentionType: String,
|
mentionType: String,
|
||||||
|
zh: Boolean,
|
||||||
contentDescription: String,
|
contentDescription: String,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -11106,21 +11446,13 @@ private fun IssueMentionUnifiedRow(
|
|||||||
"squad" -> name.take(1).uppercase().ifBlank { "S" }
|
"squad" -> name.take(1).uppercase().ifBlank { "S" }
|
||||||
else -> name.take(1).uppercase().ifBlank { "A" }
|
else -> name.take(1).uppercase().ifBlank { "A" }
|
||||||
}
|
}
|
||||||
val iconColor = when (mentionType) {
|
val iconColor = pilotMentionTypeColor(mentionType)
|
||||||
"member" -> MulticaColors.Text
|
|
||||||
"squad" -> MulticaColors.Accent
|
|
||||||
else -> MulticaColors.Accent
|
|
||||||
}
|
|
||||||
val iconBg = when (mentionType) {
|
val iconBg = when (mentionType) {
|
||||||
"member" -> MulticaColors.AccentSoft.copy(alpha = 0.12f)
|
"member" -> MulticaColors.AccentSoft.copy(alpha = 0.12f)
|
||||||
"squad" -> MulticaColors.AccentSoft.copy(alpha = 0.36f)
|
"squad" -> MulticaColors.AccentSoft.copy(alpha = 0.36f)
|
||||||
else -> MulticaColors.AccentSoft.copy(alpha = 0.24f)
|
else -> MulticaColors.AccentSoft.copy(alpha = 0.24f)
|
||||||
}
|
}
|
||||||
val typeLabel = when (mentionType) {
|
val typeLabel = pilotMentionTypeLabel(mentionType, zh)
|
||||||
"member" -> "member"
|
|
||||||
"squad" -> "squad"
|
|
||||||
else -> "agent"
|
|
||||||
}
|
|
||||||
val mentionMarkdown = when (mentionType) {
|
val mentionMarkdown = when (mentionType) {
|
||||||
"member" -> AgentMentionMarkdown.memberMarkdown(name, "")
|
"member" -> AgentMentionMarkdown.memberMarkdown(name, "")
|
||||||
"squad" -> AgentMentionMarkdown.squadMarkdown(name, "")
|
"squad" -> AgentMentionMarkdown.squadMarkdown(name, "")
|
||||||
@@ -11217,34 +11549,7 @@ private fun IssueCommentInputBar(
|
|||||||
configuration.screenHeightDp,
|
configuration.screenHeightDp,
|
||||||
).dp
|
).dp
|
||||||
val mentionItems = remember(agents, members, squads, mentionQuery) {
|
val mentionItems = remember(agents, members, squads, mentionQuery) {
|
||||||
val query = mentionQuery.trim().lowercase()
|
pilotMentionOptions(false, "", null, members, agents.filter { it.archivedAt.isBlank() }, squads, mentionQuery)
|
||||||
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(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -11410,12 +11715,15 @@ Column(
|
|||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
mentionItems.forEach { (id, name, type) ->
|
mentionItems.forEach { item ->
|
||||||
IssueMentionUnifiedRow(
|
IssueMentionUnifiedRow(
|
||||||
name = name,
|
name = item.name,
|
||||||
mentionType = type,
|
mentionType = item.type.orEmpty(),
|
||||||
contentDescription = "Issue Comment Mention $type Row $id",
|
zh = zh,
|
||||||
|
contentDescription = "Issue Comment Mention ${item.type} Row ${item.id}",
|
||||||
onClick = {
|
onClick = {
|
||||||
|
val name = item.name
|
||||||
|
val id = item.id.orEmpty()
|
||||||
val insertion = AgentMentionMarkdown.displayText(name, id)
|
val insertion = AgentMentionMarkdown.displayText(name, id)
|
||||||
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
|
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
|
||||||
onDraftChange(draft + separator + insertion + " ")
|
onDraftChange(draft + separator + insertion + " ")
|
||||||
@@ -12056,6 +12364,15 @@ 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) {
|
private fun openExternalUrl(context: Context, url: String) {
|
||||||
val target = clean(url)
|
val target = clean(url)
|
||||||
if (target.isBlank()) return
|
if (target.isBlank()) return
|
||||||
@@ -12978,6 +13295,7 @@ private fun PilotProjects(
|
|||||||
workspaceId: String,
|
workspaceId: String,
|
||||||
workspace: Models.Workspace,
|
workspace: Models.Workspace,
|
||||||
projects: List<Models.Project>,
|
projects: List<Models.Project>,
|
||||||
|
currentUser: Models.User?,
|
||||||
zh: Boolean,
|
zh: Boolean,
|
||||||
onProjectClick: (Models.Project) -> Unit,
|
onProjectClick: (Models.Project) -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
@@ -13000,6 +13318,7 @@ private fun PilotProjects(
|
|||||||
var resourceUrlsText by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf("") }
|
var resourceUrlsText by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf("") }
|
||||||
var projectMembers by remember(workspaceId) { mutableStateOf<List<Models.Member>>(emptyList()) }
|
var projectMembers by remember(workspaceId) { mutableStateOf<List<Models.Member>>(emptyList()) }
|
||||||
var projectAgents by remember(workspaceId) { mutableStateOf<List<Models.Agent>>(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 loadingProjectOptions by remember(workspaceId) { mutableStateOf(false) }
|
||||||
var saving by remember(workspaceId) { mutableStateOf(false) }
|
var saving by remember(workspaceId) { mutableStateOf(false) }
|
||||||
var pendingDelete by remember(workspaceId) { mutableStateOf<Models.Project?>(null) }
|
var pendingDelete by remember(workspaceId) { mutableStateOf<Models.Project?>(null) }
|
||||||
@@ -13099,7 +13418,7 @@ private fun PilotProjects(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(formOpen, workspaceId) {
|
LaunchedEffect(formOpen, workspaceId) {
|
||||||
if (!formOpen || projectMembers.isNotEmpty() || projectAgents.isNotEmpty() || loadingProjectOptions) return@LaunchedEffect
|
if (!formOpen || projectMembers.isNotEmpty() || projectAgents.isNotEmpty() || projectSquads.isNotEmpty() || loadingProjectOptions) return@LaunchedEffect
|
||||||
loadingProjectOptions = true
|
loadingProjectOptions = true
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching { api.members(workspaceId) to api.agents(workspaceId) }
|
runCatching { api.members(workspaceId) to api.agents(workspaceId) }
|
||||||
@@ -13109,15 +13428,26 @@ private fun PilotProjects(
|
|||||||
}.onFailure {
|
}.onFailure {
|
||||||
resultText = "${if (zh) "项目选项加载失败" else "Project options failed"}: ${it.message ?: it.toString()}"
|
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
|
loadingProjectOptions = false
|
||||||
}
|
}
|
||||||
|
|
||||||
val selectedLeadLabel = remember(leadType, leadId, projectMembers, projectAgents, zh) {
|
val projectLeadOptions = remember(currentUser, projectMembers, projectAgents, projectSquads, zh) {
|
||||||
when (leadType) {
|
pilotIssueFormAssigneeOptions(
|
||||||
"member" -> projectMembers.firstOrNull { it.userId == leadId || it.id == leadId }?.let { member ->
|
if (zh) "无负责人" else "No Lead",
|
||||||
member.displayName.ifBlank { member.email }
|
currentUser,
|
||||||
} ?: Models.shortId(leadId)
|
projectMembers,
|
||||||
"agent" -> projectAgents.firstOrNull { it.id == leadId }?.name ?: Models.shortId(leadId)
|
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)
|
||||||
else -> if (zh) "无负责人" else "No Lead"
|
else -> if (zh) "无负责人" else "No Lead"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13267,39 +13597,24 @@ private fun PilotProjects(
|
|||||||
eyebrow = if (zh) "负责人" else "Lead",
|
eyebrow = if (zh) "负责人" else "Lead",
|
||||||
title = selectedLeadLabel,
|
title = selectedLeadLabel,
|
||||||
subtitle = if (loadingProjectOptions) {
|
subtitle = if (loadingProjectOptions) {
|
||||||
if (zh) "正在加载成员和 Agent" else "Loading members and agents"
|
if (zh) "正在加载人、Agent 和小队" else "Loading people, agents, and squads"
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
contentDescription = "Project Lead Picker",
|
contentDescription = "Project Lead Picker",
|
||||||
options = listOf(
|
options = projectLeadOptions.map { lead ->
|
||||||
IssueFormMenuOption(
|
IssueFormMenuOption(
|
||||||
label = if (zh) "无负责人" else "No Lead",
|
label = lead.name,
|
||||||
selected = leadType.isBlank() || leadId.isBlank(),
|
badgeText = pilotMentionTypeLabel(lead.type, zh),
|
||||||
onClick = {
|
badgeColor = pilotMentionTypeColor(lead.type),
|
||||||
leadType = ""
|
selected = if (lead.id == null) {
|
||||||
leadId = ""
|
leadType.isBlank() || leadId.isBlank()
|
||||||
|
} else {
|
||||||
|
leadType == lead.type && leadId == lead.id
|
||||||
},
|
},
|
||||||
)
|
|
||||||
) + 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 = {
|
onClick = {
|
||||||
leadType = "member"
|
leadType = lead.type.orEmpty()
|
||||||
leadId = assigneeId
|
leadId = lead.id.orEmpty()
|
||||||
},
|
|
||||||
)
|
|
||||||
} + 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
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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,6 +810,7 @@ public final class MainActivity extends Activity {
|
|||||||
String statusValue = Models.STATUS_VALUES[Math.max(0, status.getSelectedItemPosition())];
|
String statusValue = Models.STATUS_VALUES[Math.max(0, status.getSelectedItemPosition())];
|
||||||
String priorityValue = Models.PRIORITY_VALUES[Math.max(0, priority.getSelectedItemPosition())];
|
String priorityValue = Models.PRIORITY_VALUES[Math.max(0, priority.getSelectedItemPosition())];
|
||||||
Models.Assignee selectedAssignee = assigneeAt(assignee.getSelectedItemPosition(), true);
|
Models.Assignee selectedAssignee = assigneeAt(assignee.getSelectedItemPosition(), true);
|
||||||
|
String teamId = selectedAssignee != null && "squad".equals(selectedAssignee.type) ? selectedAssignee.id : null;
|
||||||
if (demoMode) {
|
if (demoMode) {
|
||||||
upsertDemoIssue(
|
upsertDemoIssue(
|
||||||
editing ? issue.id : "demo-issue-" + (demoIssues.size() + 1),
|
editing ? issue.id : "demo-issue-" + (demoIssues.size() + 1),
|
||||||
@@ -828,7 +829,7 @@ public final class MainActivity extends Activity {
|
|||||||
return api.updateIssue(issue, titleText, desc.getText().toString(), projectId, statusValue, priorityValue, selectedAssignee);
|
return api.updateIssue(issue, titleText, desc.getText().toString(), projectId, statusValue, priorityValue, selectedAssignee);
|
||||||
}
|
}
|
||||||
if (currentWorkspace == null) throw new IllegalStateException(t("workspaceRequired"));
|
if (currentWorkspace == null) throw new IllegalStateException(t("workspaceRequired"));
|
||||||
return api.createIssue(titleText, desc.getText().toString(), currentWorkspace.id, projectId, statusValue, priorityValue, selectedAssignee);
|
return api.createIssue(titleText, desc.getText().toString(), currentWorkspace.id, projectId, teamId, statusValue, priorityValue, selectedAssignee, null, null);
|
||||||
}, saved -> afterSave.run(), error -> toast(t("saveFailed") + ": " + error.getMessage()));
|
}, saved -> afterSave.run(), error -> toast(t("saveFailed") + ": " + error.getMessage()));
|
||||||
}).show();
|
}).show();
|
||||||
}
|
}
|
||||||
@@ -1861,7 +1862,9 @@ public final class MainActivity extends Activity {
|
|||||||
private List<Models.Assignee> assignees(boolean includeEmpty) {
|
private List<Models.Assignee> assignees(boolean includeEmpty) {
|
||||||
if (memberCache.isEmpty()) memberCache = safeMembers();
|
if (memberCache.isEmpty()) memberCache = safeMembers();
|
||||||
if (agentCache.isEmpty()) agentCache = safeAgents();
|
if (agentCache.isEmpty()) agentCache = safeAgents();
|
||||||
return Models.issueAssignees(includeEmpty, t("unassigned"), currentUser, memberCache, agentCache, squadCache);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String assigneeName(String id, String type) {
|
private String assigneeName(String id, String type) {
|
||||||
|
|||||||
@@ -230,6 +230,31 @@ final class Models {
|
|||||||
return list;
|
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> {
|
static final class Page<T> {
|
||||||
final List<T> items;
|
final List<T> items;
|
||||||
final boolean hasMore;
|
final boolean hasMore;
|
||||||
|
|||||||
@@ -1021,6 +1021,8 @@ data class MulticaCupertinoActionSheetItem(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val subtitle: String? = null,
|
val subtitle: String? = null,
|
||||||
val icon: ImageVector? = null,
|
val icon: ImageVector? = null,
|
||||||
|
val badgeText: String? = null,
|
||||||
|
val badgeColor: Color = MulticaColors.Accent,
|
||||||
val contentDescription: String? = null,
|
val contentDescription: String? = null,
|
||||||
val selected: Boolean = false,
|
val selected: Boolean = false,
|
||||||
val destructive: Boolean = false,
|
val destructive: Boolean = false,
|
||||||
@@ -1056,78 +1058,40 @@ fun MulticaCupertinoActionSheet(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
message = {
|
message = {
|
||||||
if (!message.isNullOrBlank()) {
|
Column(
|
||||||
Text(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
text = message,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp),
|
) {
|
||||||
color = MulticaColors.TextTertiary,
|
if (!message.isNullOrBlank()) {
|
||||||
textAlign = TextAlign.Center,
|
Text(
|
||||||
)
|
text = message,
|
||||||
}
|
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp),
|
||||||
},
|
color = MulticaColors.TextTertiary,
|
||||||
buttons = {
|
textAlign = TextAlign.Center,
|
||||||
items.forEach { item ->
|
)
|
||||||
action(
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
onClick = {
|
}
|
||||||
performMulticaTapFeedback(haptic)
|
LazyColumn(
|
||||||
item.onClick()
|
modifier = Modifier
|
||||||
onDismissRequest()
|
.fillMaxWidth()
|
||||||
},
|
.heightIn(max = 420.dp),
|
||||||
style = if (item.destructive) AlertActionStyle.Destructive else AlertActionStyle.Default,
|
|
||||||
enabled = item.enabled,
|
|
||||||
) {
|
) {
|
||||||
Row(
|
items(items.size) { index ->
|
||||||
modifier = Modifier
|
val item = items[index]
|
||||||
.fillMaxWidth()
|
MulticaCupertinoActionSheetItemContent(
|
||||||
.semantics(mergeDescendants = true) {
|
item = item,
|
||||||
contentDescription = "Multica Cupertino Action Sheet ${item.contentDescription ?: item.title}"
|
onClick = {
|
||||||
|
if (!item.enabled) return@MulticaCupertinoActionSheetItemContent
|
||||||
|
performMulticaTapFeedback(haptic)
|
||||||
|
item.onClick()
|
||||||
|
onDismissRequest()
|
||||||
},
|
},
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
item.icon?.let { icon ->
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (item.destructive) MulticaColors.Danger else MulticaColors.TextTertiary,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
text = item.title,
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
fontSize = 15.sp,
|
|
||||||
lineHeight = 19.sp,
|
|
||||||
fontWeight = if (item.selected) FontWeight.SemiBold else FontWeight.Normal,
|
|
||||||
),
|
|
||||||
color = if (item.destructive) MulticaColors.Danger else MulticaColors.TextPrimary,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
if (!item.subtitle.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
text = item.subtitle,
|
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp),
|
|
||||||
color = MulticaColors.TextTertiary,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item.selected) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.CheckCircle,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MulticaColors.Accent,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
buttons = {
|
||||||
if (!cancelText.isNullOrBlank()) {
|
if (!cancelText.isNullOrBlank()) {
|
||||||
action(
|
action(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -1148,6 +1112,83 @@ fun MulticaCupertinoActionSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MulticaCupertinoActionSheetItemContent(
|
||||||
|
item: MulticaCupertinoActionSheetItem,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
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}"
|
||||||
|
},
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
item.icon?.let { icon ->
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (item.destructive) MulticaColors.Danger else MulticaColors.TextTertiary,
|
||||||
|
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,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 19.sp,
|
||||||
|
fontWeight = if (item.selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
),
|
||||||
|
color = if (item.destructive) MulticaColors.Danger else MulticaColors.TextPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
if (!item.subtitle.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = item.subtitle,
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp),
|
||||||
|
color = MulticaColors.TextTertiary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MulticaColors.Accent,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalCupertinoApi::class)
|
@OptIn(ExperimentalCupertinoApi::class)
|
||||||
fun MulticaCupertinoAlertDialog(
|
fun MulticaCupertinoAlertDialog(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<main>
|
<main>
|
||||||
<h1>Multi-Casual Android</h1>
|
<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>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.2/Multi-Casual-Android-v0.1.2.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.4/Multi-Casual-Android-v0.1.4.apk">GitHub Releases</a>.</p>
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
<p>Site placeholder published from the public Android source repository.</p>
|
<p>Site placeholder published from the public Android source repository.</p>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"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 = {
|
const release = {
|
||||||
packageName: "ai.multicasual.app",
|
packageName: "ai.multicasual.app",
|
||||||
sha256: "317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4",
|
sha256: "3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49",
|
||||||
githubUrl: "https://github.com/park0er/multi-casual-android",
|
githubUrl: "https://github.com/park0er/multi-casual-android",
|
||||||
releasesUrl: "https://github.com/park0er/multi-casual-android/releases/download/v0.1.3/Multi-Casual-Android-v0.1.3.apk",
|
releasesUrl: "https://github.com/park0er/multi-casual-android/releases/download/v0.1.5/Multi-Casual-Android-v0.1.5.apk",
|
||||||
iosUrl: "https://github.com/park0er/multi-casual-ios-app",
|
iosUrl: "https://github.com/park0er/multi-casual-ios-app",
|
||||||
multicaUrl: "https://multica.ai",
|
multicaUrl: "https://multica.ai",
|
||||||
apiUrl: "https://api.multica.ai",
|
apiUrl: "https://api.multica.ai",
|
||||||
@@ -640,7 +640,7 @@ export default function App() {
|
|||||||
<code>{release.sha256}</code>
|
<code>{release.sha256}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pre><code>{`adb install -r Multi-Casual-Android-v0.1.3.apk`}</code></pre>
|
<pre><code>{`adb install -r Multi-Casual-Android-v0.1.5.apk`}</code></pre>
|
||||||
<div className="cta-row compact">
|
<div className="cta-row compact">
|
||||||
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.download.latest}</a>
|
<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>
|
<a className="button secondary" href={release.githubUrl}>{t.download.source}</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user