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.
|
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.5.apk`
|
- Current signed APK: `Multi-Casual-Android-v0.1.3.apk`
|
||||||
- SHA256: `3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
|
- SHA256: `317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4`
|
||||||
|
|
||||||
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.5.apk`
|
- 当前签名 APK:`Multi-Casual-Android-v0.1.3.apk`
|
||||||
- SHA256:`3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
|
- SHA256:`317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4`
|
||||||
|
|
||||||
本地开发命令生成的 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 5
|
versionCode 3
|
||||||
versionName "0.1.4"
|
versionName "0.1.2"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -259,14 +259,19 @@ 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 {
|
||||||
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);
|
||||||
Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
|
body.put("workspace_id", workspaceId);
|
||||||
String teamId, String status, String priority, Models.Assignee assignee, String dueDate,
|
if (projectId != null) body.put("project_id", projectId);
|
||||||
String parentIssueId) throws Exception {
|
if (parentIssueId != null && !parentIssueId.isEmpty()) body.put("parent_issue_id", parentIssueId);
|
||||||
JSONObject body = IssuePayloads.createIssue(title, description, workspaceId, projectId, teamId, status, priority,
|
if (status != null) body.put("status", status);
|
||||||
assignee, dueDate, parentIssueId);
|
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 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,31 +526,6 @@ 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);
|
||||||
@@ -776,7 +756,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, workspaceIdHeaders(workspaceId));
|
JSONObject json = requestObject("GET", "/api/squads", query, null);
|
||||||
return parseArray(extractArray(json, "squads"), Models.Squad::new);
|
return parseArray(extractArray(json, "squads"), Models.Squad::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1531,17 +1511,6 @@ 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("?");
|
||||||
@@ -1593,14 +1562,6 @@ 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();
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
@@ -13,11 +12,6 @@ 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
|
||||||
@@ -36,7 +30,6 @@ 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
|
||||||
@@ -295,22 +288,6 @@ 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()
|
||||||
@@ -451,56 +428,12 @@ private data class PilotSkillDetailData(
|
|||||||
val files: List<Models.SkillFile>,
|
val files: List<Models.SkillFile>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class PilotMentionOption(
|
private data class PilotAssigneeOption(
|
||||||
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
|
||||||
|
|
||||||
@@ -2067,7 +2000,6 @@ 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,
|
||||||
@@ -6841,8 +6773,6 @@ 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,
|
||||||
@@ -6856,8 +6786,6 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -6897,7 +6825,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 = api.squads(session.workspace.id),
|
squads = runCatching { api.squads(session.workspace.id) }.getOrElse { emptyList() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6912,13 +6840,14 @@ private fun PilotIssueForm(
|
|||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
val options = loaded.getOrThrow()
|
val options = loaded.getOrThrow()
|
||||||
val assignees = pilotIssueFormAssigneeOptions(
|
val assignees = Models.issueAssignees(
|
||||||
|
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()) }
|
||||||
@@ -7018,7 +6947,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -7028,7 +6956,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, teamId, status, priority, assignee, dueDateText, parentIssueId)
|
api.createIssue(titleText, description, session.workspace.id, projectId, 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)
|
||||||
}
|
}
|
||||||
@@ -7265,8 +7193,6 @@ 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 },
|
||||||
)
|
)
|
||||||
@@ -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 {
|
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
|
||||||
@@ -9719,7 +9368,6 @@ 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) }
|
||||||
@@ -9756,6 +9404,7 @@ 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) {
|
||||||
@@ -10001,15 +9650,11 @@ private fun PilotIssueDetail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun openAttachment(attachment: Models.Attachment) {
|
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()) {
|
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))
|
||||||
@@ -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 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) {
|
||||||
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) {
|
LaunchedEffect(parentId) {
|
||||||
delay(180)
|
delay(180)
|
||||||
@@ -11338,15 +11002,12 @@ Column(
|
|||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
mentionItems.forEach { item ->
|
mentionItems.forEach { (id, name, type) ->
|
||||||
IssueMentionUnifiedRow(
|
IssueMentionUnifiedRow(
|
||||||
name = item.name,
|
name = name,
|
||||||
mentionType = item.type.orEmpty(),
|
mentionType = type,
|
||||||
zh = zh,
|
contentDescription = "Issue Comment Reply Mention $type Row $id",
|
||||||
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 + " ")
|
||||||
@@ -11437,7 +11098,6 @@ 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,
|
||||||
) {
|
) {
|
||||||
@@ -11446,13 +11106,21 @@ 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 = pilotMentionTypeColor(mentionType)
|
val iconColor = when (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 = pilotMentionTypeLabel(mentionType, zh)
|
val typeLabel = when (mentionType) {
|
||||||
|
"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, "")
|
||||||
@@ -11549,7 +11217,34 @@ private fun IssueCommentInputBar(
|
|||||||
configuration.screenHeightDp,
|
configuration.screenHeightDp,
|
||||||
).dp
|
).dp
|
||||||
val mentionItems = remember(agents, members, squads, mentionQuery) {
|
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(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -11715,15 +11410,12 @@ Column(
|
|||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
mentionItems.forEach { item ->
|
mentionItems.forEach { (id, name, type) ->
|
||||||
IssueMentionUnifiedRow(
|
IssueMentionUnifiedRow(
|
||||||
name = item.name,
|
name = name,
|
||||||
mentionType = item.type.orEmpty(),
|
mentionType = type,
|
||||||
zh = zh,
|
contentDescription = "Issue Comment Mention $type Row $id",
|
||||||
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 + " ")
|
||||||
@@ -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) {
|
private fun openExternalUrl(context: Context, url: String) {
|
||||||
val target = clean(url)
|
val target = clean(url)
|
||||||
if (target.isBlank()) return
|
if (target.isBlank()) return
|
||||||
@@ -13295,7 +12978,6 @@ 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,
|
||||||
@@ -13318,7 +13000,6 @@ 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) }
|
||||||
@@ -13418,7 +13099,7 @@ private fun PilotProjects(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(formOpen, workspaceId) {
|
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
|
loadingProjectOptions = true
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching { api.members(workspaceId) to api.agents(workspaceId) }
|
runCatching { api.members(workspaceId) to api.agents(workspaceId) }
|
||||||
@@ -13428,26 +13109,15 @@ 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 projectLeadOptions = remember(currentUser, projectMembers, projectAgents, projectSquads, zh) {
|
val selectedLeadLabel = remember(leadType, leadId, projectMembers, projectAgents, zh) {
|
||||||
pilotIssueFormAssigneeOptions(
|
when (leadType) {
|
||||||
if (zh) "无负责人" else "No Lead",
|
"member" -> projectMembers.firstOrNull { it.userId == leadId || it.id == leadId }?.let { member ->
|
||||||
currentUser,
|
member.displayName.ifBlank { member.email }
|
||||||
projectMembers,
|
} ?: Models.shortId(leadId)
|
||||||
projectAgents,
|
"agent" -> projectAgents.firstOrNull { it.id == leadId }?.name ?: Models.shortId(leadId)
|
||||||
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13597,24 +13267,39 @@ 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 people, agents, and squads"
|
if (zh) "正在加载成员和 Agent" else "Loading members and agents"
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
contentDescription = "Project Lead Picker",
|
contentDescription = "Project Lead Picker",
|
||||||
options = projectLeadOptions.map { lead ->
|
options = listOf(
|
||||||
IssueFormMenuOption(
|
IssueFormMenuOption(
|
||||||
label = lead.name,
|
label = if (zh) "无负责人" else "No Lead",
|
||||||
badgeText = pilotMentionTypeLabel(lead.type, zh),
|
selected = leadType.isBlank() || leadId.isBlank(),
|
||||||
badgeColor = pilotMentionTypeColor(lead.type),
|
|
||||||
selected = if (lead.id == null) {
|
|
||||||
leadType.isBlank() || leadId.isBlank()
|
|
||||||
} else {
|
|
||||||
leadType == lead.type && leadId == lead.id
|
|
||||||
},
|
|
||||||
onClick = {
|
onClick = {
|
||||||
leadType = lead.type.orEmpty()
|
leadType = ""
|
||||||
leadId = lead.id.orEmpty()
|
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 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),
|
||||||
@@ -829,7 +828,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, 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()));
|
}, saved -> afterSave.run(), error -> toast(t("saveFailed") + ": " + error.getMessage()));
|
||||||
}).show();
|
}).show();
|
||||||
}
|
}
|
||||||
@@ -1862,9 +1861,7 @@ 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();
|
||||||
if (squadCache.isEmpty()) squadCache = safeSquads();
|
return Models.issueAssignees(includeEmpty, t("unassigned"), currentUser, memberCache, agentCache, squadCache);
|
||||||
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,31 +230,6 @@ 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,8 +1021,6 @@ 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,
|
||||||
@@ -1058,40 +1056,78 @@ fun MulticaCupertinoActionSheet(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
message = {
|
message = {
|
||||||
Column(
|
if (!message.isNullOrBlank()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Text(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
text = message,
|
||||||
) {
|
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp),
|
||||||
if (!message.isNullOrBlank()) {
|
color = MulticaColors.TextTertiary,
|
||||||
Text(
|
textAlign = TextAlign.Center,
|
||||||
text = message,
|
)
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp),
|
|
||||||
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,
|
|
||||||
onClick = {
|
|
||||||
if (!item.enabled) return@MulticaCupertinoActionSheetItemContent
|
|
||||||
performMulticaTapFeedback(haptic)
|
|
||||||
item.onClick()
|
|
||||||
onDismissRequest()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
buttons = {
|
buttons = {
|
||||||
|
items.forEach { item ->
|
||||||
|
action(
|
||||||
|
onClick = {
|
||||||
|
performMulticaTapFeedback(haptic)
|
||||||
|
item.onClick()
|
||||||
|
onDismissRequest()
|
||||||
|
},
|
||||||
|
style = if (item.destructive) AlertActionStyle.Destructive else AlertActionStyle.Default,
|
||||||
|
enabled = item.enabled,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!cancelText.isNullOrBlank()) {
|
if (!cancelText.isNullOrBlank()) {
|
||||||
action(
|
action(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -1112,83 +1148,6 @@ 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.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>
|
</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>
|
||||||
|
|||||||
@@ -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 = {
|
const release = {
|
||||||
packageName: "ai.multicasual.app",
|
packageName: "ai.multicasual.app",
|
||||||
sha256: "3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49",
|
sha256: "317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4",
|
||||||
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.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",
|
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.5.apk`}</code></pre>
|
<pre><code>{`adb install -r Multi-Casual-Android-v0.1.3.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