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