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
|
# 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 | [简体中文](#简体中文)
|
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.
|
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.
|
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
|
- 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`
|
- Service API used by this app: `https://api.multica.ai`
|
||||||
- Android package: `ai.multicasual.app`
|
- 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:
|
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.4.apk`
|
- Current signed APK: `Multi-Casual-Android-v0.1.3.apk`
|
||||||
- SHA256: `35705dcc46fdc5fd9d0d718f50844f94bd55e49e236e4ca8c8b7b2e4f89b6c2f`
|
- 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.
|
||||||
|
|
||||||
@@ -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 和设置等能力。
|
这个 App 面向已经使用 Multica 的用户,把核心 workspace 工作流带到 Android 上:Issue 浏览、Issue 详情、评论、项目、Agent、Skill、Label 和设置等能力。
|
||||||
|
|
||||||
- Multica 网站:https://multica.ai
|
- Multica 网站:https://multica.ai
|
||||||
- Multica Web 应用:https://app.multica.ai
|
|
||||||
- 本 App 使用的服务 API:`https://api.multica.ai`
|
- 本 App 使用的服务 API:`https://api.multica.ai`
|
||||||
- Android 包名:`ai.multicasual.app`
|
- Android 包名:`ai.multicasual.app`
|
||||||
|
|
||||||
@@ -161,7 +157,7 @@ Analytics 是 opt-in。App 会先请求用户同意,然后才会上报 analyti
|
|||||||
|
|
||||||
- 中国大陆路线:友盟
|
- 中国大陆路线:友盟
|
||||||
- 全球路线:PostHog
|
- 全球路线:PostHog
|
||||||
- App 不会有意上报页面访问、聊天内容、文件内容、邮箱、token 或精确位置。
|
- App 只上报打开事件,绝不会上报页面访问、聊天内容、文件内容、邮箱、token 或精确位置等任何其余信息,源码公开可扫描隐私保证
|
||||||
|
|
||||||
如果本地不配置 analytics key,analytics provider 不会启动。
|
如果本地不配置 analytics key,analytics provider 不会启动。
|
||||||
|
|
||||||
@@ -170,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.4.apk`
|
- 当前签名 APK:`Multi-Casual-Android-v0.1.3.apk`
|
||||||
- SHA256:`35705dcc46fdc5fd9d0d718f50844f94bd55e49e236e4ca8c8b7b2e4f89b6c2f`
|
- 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: "35705dcc46fdc5fd9d0d718f50844f94bd55e49e236e4ca8c8b7b2e4f89b6c2f",
|
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.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",
|
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.4.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