72 Commits
Author SHA1 Message Date
Xisheng-Zhao 62f87bbc38 Update release metadata for v0.1.5 2026-06-20 12:45:23 +08:00
Xisheng-Zhao 3788d8e592 Restore README badge header 2026-06-20 04:12:46 +08:00
Xisheng-Zhao 50ec6e80e1 Trigger GitHub Pages deploy for v0.1.4 2026-06-20 04:07:37 +08:00
Xisheng-Zhao c55230deed Finalize independent Android public repository 2026-06-20 04:01:35 +08:00
Xisheng-Zhao 202076a1b0 Prepare independent Android public release 2026-06-20 03:59:49 +08:00
Xisheng-Zhao ffb4ce404f Allow WebView attachment document loads 2026-06-20 03:45:21 +08:00
Xisheng-Zhao 47bb6088df Maximize attachment preview sheet space 2026-06-20 03:35:11 +08:00
Xisheng-Zhao 43e84df960 Keep issue position when previewing attachments 2026-06-20 03:27:04 +08:00
Xisheng-Zhao af1ea96832 Add built-in attachment markdown html preview 2026-06-20 03:12:18 +08:00
Xisheng-Zhao 604a08ebc4 Prepare v0.1.3 public release 2026-06-19 21:03:29 +08:00
Xisheng-Zhao 45412e5fdd Make action sheet pickers scrollable 2026-06-19 20:39:43 +08:00
Xisheng-Zhao 014e4e7761 Support squads in project lead picker 2026-06-19 20:28:15 +08:00
Xisheng-Zhao 186cec5470 Support squads in classic issue form 2026-06-19 20:21:56 +08:00
Xisheng-Zhao ee033e5044 Show squads first in issue assignee picker 2026-06-19 19:47:51 +08:00
Xisheng-Zhao f913e4d956 Fix squad loading for issue assignees 2026-06-19 19:33:44 +08:00
Xisheng-Zhao 55882171a7 Unify issue assignee mention options 2026-06-19 19:13:48 +08:00
Xisheng-Zhao e99195ca69 Support squad selection when creating issues 2026-06-19 18:55:32 +08:00
Xisheng-Zhao 19d941fa0f Update promo video: add brand outro with logo 2026-06-09 13:55:34 +08:00
Xisheng-Zhao eea07f74f4 Unify button and pill font-size to 15px 2026-06-09 13:31:36 +08:00
Xisheng-Zhao 14297b83da Add iOS liquid-glass pill to hero CTA (option A) 2026-06-09 13:26:18 +08:00
Xisheng-Zhao 57b30bcd31 Add iOS liquid-glass pill + Android robot pill to footer 2026-06-09 13:17:17 +08:00
Xisheng-Zhao 2b760752bd Compress screenshots (5MB→1.4MB) and favicon (140K→5K) 2026-06-08 23:08:35 +08:00
Xisheng-Zhao bf53296006 Fix GitHub Pages base path and favicon, add Vite config 2026-06-08 22:50:42 +08:00
Xisheng-Zhao dbf7c75581 Build GitHub Pages site with npm install 2026-06-08 21:54:48 +08:00
Xisheng-Zhao 41f09fe6d1 Fix GitHub Pages site build cache path 2026-06-08 21:52:55 +08:00
Xisheng-Zhao ad8bd857be Switch download button to direct APK URL 2026-06-08 21:51:10 +08:00
Xisheng-Zhao 84a2f84355 Merge marketing site and promo video update 2026-06-08 21:09:00 +08:00
Xisheng-Zhao 60100eb132 Fix mobile layout: hero padding, phone stack sizing, section spacing 2026-06-08 20:30:35 +08:00
Xisheng-Zhao 15130bb222 Replace browser tab favicon with app logo 2026-06-08 20:21:52 +08:00
Xisheng-Zhao 5b1263a178 Polish hero CTAs: scroll to download, GitHub logo, underline star link, download button labels 2026-06-08 20:15:45 +08:00
Xisheng-Zhao 0eb7d17a3a Add GitHub Actions workflow for Pages deployment 2026-06-08 19:47:52 +08:00
Xisheng-Zhao 73e70ca4f1 Enlarge star hint text and add GitHub repo hyperlink 2026-06-08 19:18:03 +08:00
Xisheng-Zhao ff8af0b81b Fix license reference from MIT to GPLv3 2026-06-08 18:01:01 +08:00
Xisheng-Zhao b16a85aca8 Add GitHub star call-to-action in download section 2026-06-08 18:00:27 +08:00
Xisheng-Zhao ead6275f8b Fix dark nav bar: white gradient overlay on phone-screen bottom 2026-06-08 17:55:50 +08:00
Xisheng-Zhao c2500283d5 Clip dark nav bar at phone screen bottom (mask gradient + object-position shift) 2026-06-08 17:48:05 +08:00
Xisheng-Zhao 52bb4cf5bf Update embedded promo video with aligned typing container 2026-06-08 17:38:12 +08:00
Xisheng-Zhao 5316098a0c Merge branch 'honest-blowfish' 2026-06-08 17:38:02 +08:00
Xisheng-Zhao 35b1639241 Polish website: blue-tinted background gradient, smaller workspace phone
- Body gradient now stays blue-tinted (was fading to white at bottom)
- Workspace phone shell reduced from 320px to 260px for full-screen visibility
- Matches reference design in d700 worktree
2026-06-08 17:36:55 +08:00
Xisheng-Zhao 06ab3f74eb Make new chat creation lazy 2026-06-08 17:19:07 +08:00
Xisheng-Zhao 6df3b0d53e Replace blue M icon with app logo in nav bar 2026-06-08 17:16:38 +08:00
Xisheng-Zhao 8a0b1703c1 Align typing comment box with screenshot input area 2026-06-08 17:07:04 +08:00
Xisheng-Zhao b3469a5e5d Fix typing overlay and loop background music to 32s 2026-06-08 16:40:58 +08:00
Xisheng-Zhao 2d69bb55f7 Re-render promo video with background music and Chinese titles 2026-06-08 16:31:31 +08:00
Xisheng-Zhao 2dbf159b1b Polish hero phone stack, workspace sizing, and background gradient 2026-06-08 16:15:03 +08:00
Xisheng-Zhao 1b56284479 Embed final product promo video 2026-06-08 13:27:29 +08:00
Xisheng-Zhao b04552f6fe Refresh marketing site with product-led story
- Rewrite App.tsx: interactive 9-step product journey, bilingual EN/ZH
- All 14 real product screenshots integrated
- Relationship section with upstream contribution context
- Workspace assets grid with chip navigation (Projects/Agents/Runtimes/Skills/Settings)
- TypewriterEffect with true per-character input via motion value
- Mention picker overlay animation
- Auto-play journey on scroll (IntersectionObserver)
- prefers-reduced-motion support throughout
- No 'public' language anywhere in the build output
2026-06-08 13:12:37 +08:00
Xisheng-Zhao a1cfdb1367 Prepare public marketing screenshots
- Copy 14 real product screenshots to site/src/assets/screenshots/
- Copy 14 real product screenshots to promo-video/assets/public-screens/
- Remove old placeholder PNGs from site assets
- No content sanitization applied (per user confirmation)
2026-06-08 13:05:19 +08:00
Xisheng-Zhao ac3e668019 Merge branch 'honest-blowfish' 2026-06-08 12:37:15 +08:00
Xisheng-Zhao 6e514521df Add cancel control to issue comment composer 2026-06-08 12:31:45 +08:00
Xisheng-Zhao 28ecdcab8a Avoid stale pending chat refresh state 2026-06-08 12:02:07 +08:00
Xisheng-Zhao f005de88ca Refine chat composer streaming experience 2026-06-08 11:59:18 +08:00
Xisheng-Zhao d8337a5b4c Merge branch 'giant-armadillo' 2026-06-08 11:43:37 +08:00
Xisheng-Zhao 01acd39ac9 fix: reply sort direction now follows the comment sort pill (descending param)
Replies within a comment thread were always sorted ascending regardless of
the descending parameter. Now they respect it, matching root comment sort.
2026-06-07 17:32:04 +08:00
Xisheng-Zhao cfa608211c Improve chat and comment composer experience 2026-06-07 04:34:22 +08:00
Xisheng-Zhao b6d61135ca Add squad mentions and assignee support 2026-06-07 04:08:17 +08:00
Xisheng-Zhao ff09efe3a1 Checkpoint public Android release state 2026-06-07 03:38:36 +08:00
Xisheng-Zhao d9faa6c0c6 Prepare public-only GitHub publication 2026-06-03 18:45:29 +08:00
Xisheng-Zhao 10e55d9477 Complete public analytics SDK setup 2026-06-03 18:16:40 +08:00
Xisheng-Zhao 3cd9a6bd53 Wire Umeng public app key and preinit 2026-06-03 17:50:56 +08:00
Xisheng-Zhao 0ccfc653da Prepare Multi-Casual public analytics release 2026-06-03 02:15:20 +08:00
Xisheng-Zhao f77fa6fa0d Add analytics tab view analytics 2026-05-18 17:46:23 +08:00
Xisheng-Zhao dc7dfd2fba add launcher icons and clean inbox rows 2026-05-18 00:08:56 +08:00
Xisheng-Zhao b435a9bf7d fix issue mention links in markdown 2026-05-17 18:48:20 +08:00
Xisheng-Zhao 36377f5047 support rich issue comment links 2026-05-17 18:32:17 +08:00
Xisheng-Zhao 271918a8b1 align issue comment attachment controls 2026-05-17 18:07:36 +08:00
Xisheng-Zhao a0051182e9 polish issue detail screenshot feedback 2026-05-17 17:51:37 +08:00
Xisheng-Zhao a29e6512d2 verify feature sync selectable text and localization 2026-05-17 05:27:21 +08:00
Xisheng-Zhao e07e59df4e add android public and public build flavors 2026-05-17 05:16:51 +08:00
Xisheng-Zhao 5ca191a872 add markdown table parity verification 2026-05-17 05:03:47 +08:00
Xisheng-Zhao 412a298268 polish Android iOS-style issue skills and forms 2026-05-17 04:55:52 +08:00
Xisheng-Zhao 4a0c8c8b8b initial android app snapshot 2026-05-17 04:20:20 +08:00
12 changed files with 746 additions and 210 deletions
+5 -5
View File
@@ -4,7 +4,7 @@
An independent Android companion app for Multica users. An independent Android companion app for Multica users.
[![release](https://img.shields.io/badge/release-v0.1.3-E4DCC5?style=flat-square&labelColor=2B2A28)](https://github.com/park0er/multi-casual-android/releases/latest) [![cn site](https://img.shields.io/badge/cn%20site-multi--casual--china--8temdtzs.zh--cn.edgeone.cool-E4DCC5?style=flat-square&labelColor=2B2A28)](https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/) [![global site](https://img.shields.io/badge/global%20site-multi--casual--android.pages.dev-E4DCC5?style=flat-square&labelColor=2B2A28)](https://multi-casual-android.pages.dev/) [![release](https://img.shields.io/badge/release-v0.1.5-E4DCC5?style=flat-square&labelColor=2B2A28)](https://github.com/park0er/multi-casual-android/releases/latest) [![cn site](https://img.shields.io/badge/cn%20site-multi--casual--china--8temdtzs.zh--cn.edgeone.cool-E4DCC5?style=flat-square&labelColor=2B2A28)](https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/) [![global site](https://img.shields.io/badge/global%20site-multi--casual--android.pages.dev-E4DCC5?style=flat-square&labelColor=2B2A28)](https://multi-casual-android.pages.dev/)
English | [简体中文](#简体中文) English | [简体中文](#简体中文)
@@ -51,8 +51,8 @@ You can also build the app without analytics keys. In that case, analytics provi
For normal installation, download the signed release APK from GitHub Releases: For normal installation, download the signed release APK from GitHub Releases:
- Latest release: https://github.com/park0er/multi-casual-android/releases/latest - Latest release: https://github.com/park0er/multi-casual-android/releases/latest
- Current signed APK: `Multi-Casual-Android-v0.1.3.apk` - Current signed APK: `Multi-Casual-Android-v0.1.5.apk`
- SHA256: `317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4` - SHA256: `3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
The debug APK produced by local development commands is not intended for public distribution. The debug APK produced by local development commands is not intended for public distribution.
@@ -166,8 +166,8 @@ Analytics 是 opt-in。App 会先请求用户同意,然后才会上报 analyti
普通用户安装时,请从 GitHub Releases 下载签名 release APK 普通用户安装时,请从 GitHub Releases 下载签名 release APK
- 最新 releasehttps://github.com/park0er/multi-casual-android/releases/latest - 最新 releasehttps://github.com/park0er/multi-casual-android/releases/latest
- 当前签名 APK`Multi-Casual-Android-v0.1.3.apk` - 当前签名 APK`Multi-Casual-Android-v0.1.5.apk`
- SHA256`317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4` - SHA256`3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
本地开发命令生成的 debug APK 不用于公开分发。 本地开发命令生成的 debug APK 不用于公开分发。
+2 -2
View File
@@ -51,8 +51,8 @@ android {
applicationId "ai.multicasual.app" applicationId "ai.multicasual.app"
minSdk 24 minSdk 24
targetSdk 36 targetSdk 36
versionCode 3 versionCode 5
versionName "0.1.2" versionName "0.1.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
+52 -13
View File
@@ -259,19 +259,14 @@ final class ApiClient {
Models.Issue createIssue(String title, String description, String workspaceId, String projectId, Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
String status, String priority, Models.Assignee assignee, String dueDate, String status, String priority, Models.Assignee assignee, String dueDate,
String parentIssueId) throws Exception { String parentIssueId) throws Exception {
JSONObject body = new JSONObject(); return createIssue(title, description, workspaceId, projectId, null, status, priority, assignee, dueDate, parentIssueId);
body.put("title", title);
body.put("description", description == null || description.isEmpty() ? JSONObject.NULL : description);
body.put("workspace_id", workspaceId);
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)); return new Models.Issue(requestObject("POST", "/api/issues", query(null, "workspace_id", workspaceId), body));
} }
@@ -526,6 +521,31 @@ final class ApiClient {
return new Models.Attachment(json); return new Models.Attachment(json);
} }
String downloadAttachmentText(String url) throws Exception {
if (url == null || url.trim().isEmpty()) return "";
HttpURLConnection conn = (HttpURLConnection) new URL(url.trim()).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(15000);
conn.setReadTimeout(30000);
conn.setRequestProperty("Accept", "text/markdown,text/html,application/xhtml+xml,application/xml,text/xml,text/*,*/*;q=0.8");
conn.setRequestProperty("X-Client-Platform", "android");
conn.setRequestProperty("X-Client-Version", "debug");
String token = authStore.token();
if (token != null && !token.isEmpty() && url.startsWith(BuildConfig.MULTICA_API_BASE_URL)) {
conn.setRequestProperty("Authorization", "Bearer " + token);
}
String cookieHeader = authStore.cloudFrontCookieHeader();
if (cookieHeader != null && !cookieHeader.isEmpty() && url.contains("static.multica.ai")) {
conn.setRequestProperty("Cookie", cookieHeader);
}
int code = conn.getResponseCode();
saveResponseCookies(conn);
InputStream stream = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream();
String text = readTextPreservingLines(stream);
if (code < 200 || code >= 300) throw new ApiException(code, text);
return text;
}
void deleteAttachment(String workspaceId, String attachmentId) throws Exception { void deleteAttachment(String workspaceId, String attachmentId) throws Exception {
requestObject("DELETE", "/api/attachments/" + encPath(attachmentId), requestObject("DELETE", "/api/attachments/" + encPath(attachmentId),
query(null, "workspace_id", workspaceId), null); query(null, "workspace_id", workspaceId), null);
@@ -756,7 +776,7 @@ final class ApiClient {
List<Models.Squad> squads(String workspaceId, boolean includeArchived) throws Exception { List<Models.Squad> squads(String workspaceId, boolean includeArchived) throws Exception {
JSONObject query = query(null, "workspace_id", workspaceId); JSONObject query = query(null, "workspace_id", workspaceId);
if (includeArchived) query.put("include_archived", "true"); if (includeArchived) query.put("include_archived", "true");
JSONObject json = requestObject("GET", "/api/squads", query, null); JSONObject json = requestObject("GET", "/api/squads", query, null, workspaceIdHeaders(workspaceId));
return parseArray(extractArray(json, "squads"), Models.Squad::new); return parseArray(extractArray(json, "squads"), Models.Squad::new);
} }
@@ -1511,6 +1531,17 @@ final class ApiClient {
return sb.toString(); return sb.toString();
} }
private static String readTextPreservingLines(InputStream stream) throws Exception {
if (stream == null) return "";
StringBuilder sb = new StringBuilder();
char[] buffer = new char[4096];
try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
int count;
while ((count = reader.read(buffer)) != -1) sb.append(buffer, 0, count);
}
return sb.toString();
}
private static String queryString(JSONObject query) throws Exception { private static String queryString(JSONObject query) throws Exception {
if (query == null || query.length() == 0) return ""; if (query == null || query.length() == 0) return "";
StringBuilder sb = new StringBuilder("?"); StringBuilder sb = new StringBuilder("?");
@@ -1562,6 +1593,14 @@ final class ApiClient {
throw new IllegalArgumentException("workspace_id is required. Select a workspace first."); throw new IllegalArgumentException("workspace_id is required. Select a workspace first.");
} }
static Map<String, String> workspaceIdHeaders(String workspaceId) {
String id = workspaceId == null ? "" : workspaceId.trim();
if (!id.isEmpty()) {
return Collections.singletonMap("X-Workspace-ID", id);
}
throw new IllegalArgumentException("workspace_id is required. Select a workspace first.");
}
private static boolean isBlankQueryValue(Object value) { private static boolean isBlankQueryValue(Object value) {
if (value == null || value == JSONObject.NULL) return true; if (value == null || value == JSONObject.NULL) return true;
return String.valueOf(value).trim().isEmpty(); return String.valueOf(value).trim().isEmpty();
@@ -0,0 +1,86 @@
package ai.multica.app;
import java.net.URI;
final class AttachmentPreview {
static final String BASE_URL = "https://attachment-preview.local/";
enum Kind {
MARKDOWN,
HTML,
XML,
DOWNLOAD
}
private AttachmentPreview() {}
static Kind kindFor(String contentType, String filename) {
String type = mediaType(contentType);
if (isMarkdownType(type)) return Kind.MARKDOWN;
if (isHtmlType(type)) return Kind.HTML;
if (isXmlType(type)) return Kind.XML;
String name = filename == null ? "" : filename.trim().toLowerCase(java.util.Locale.ROOT);
if (name.endsWith(".md") || name.endsWith(".markdown")) return Kind.MARKDOWN;
if (name.endsWith(".html") || name.endsWith(".htm")) return Kind.HTML;
if (name.endsWith(".xml")) return Kind.XML;
return Kind.DOWNLOAD;
}
static boolean isLocalPreviewUrl(String url) {
if (url == null || url.trim().isEmpty()) return false;
try {
URI uri = URI.create(url.trim());
String scheme = uri.getScheme();
String host = uri.getHost();
return "https".equalsIgnoreCase(scheme) && "attachment-preview.local".equalsIgnoreCase(host);
} catch (IllegalArgumentException ignored) {
return false;
}
}
static boolean isWebViewInternalDocumentUrl(String url) {
if (url == null) return false;
String value = url.trim().toLowerCase(java.util.Locale.ROOT);
return value.equals("about:blank") || value.startsWith("data:text/html");
}
static String xmlPreviewDocument(String xml) {
return "<!doctype html><html><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<style>body{margin:0;padding:16px;background:#fff;color:#111;font:14px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;}pre{white-space:pre-wrap;word-break:break-word;margin:0;}</style>"
+ "</head><body><pre>"
+ escapeHtml(xml == null ? "" : xml)
+ "</pre></body></html>";
}
private static String mediaType(String contentType) {
if (contentType == null) return "";
int semicolon = contentType.indexOf(';');
String value = semicolon >= 0 ? contentType.substring(0, semicolon) : contentType;
return value.trim().toLowerCase(java.util.Locale.ROOT);
}
private static boolean isMarkdownType(String type) {
return "text/markdown".equals(type)
|| "text/x-markdown".equals(type)
|| "application/markdown".equals(type)
|| "application/x-markdown".equals(type);
}
private static boolean isHtmlType(String type) {
return "text/html".equals(type) || "application/xhtml+xml".equals(type);
}
private static boolean isXmlType(String type) {
return "text/xml".equals(type) || "application/xml".equals(type) || type.endsWith("+xml");
}
private static String escapeHtml(String value) {
return value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
}
@@ -1,6 +1,7 @@
package ai.multica.app package ai.multica.app
import android.app.Activity import android.app.Activity
import android.annotation.SuppressLint
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
@@ -12,6 +13,11 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -30,6 +36,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
@@ -288,6 +295,22 @@ class ComposePilotActivity : ComponentActivity() {
} }
return return
} }
if (intent.getBooleanExtra("preview_attachment_markdown", false)) {
setContent {
MulticaTheme {
PilotAttachmentMarkdownPreviewFixture()
}
}
return
}
if (intent.getBooleanExtra("preview_attachment_html", false)) {
setContent {
MulticaTheme {
PilotAttachmentHtmlPreviewFixture()
}
}
return
}
if (intent.getBooleanExtra("preview_no_workspace_onboarding", false)) { if (intent.getBooleanExtra("preview_no_workspace_onboarding", false)) {
val previewCreationSuccess = intent.getBooleanExtra("preview_no_workspace_success", false) val previewCreationSuccess = intent.getBooleanExtra("preview_no_workspace_success", false)
val previewUser = Models.User(JSONObject() val previewUser = Models.User(JSONObject()
@@ -428,12 +451,56 @@ private data class PilotSkillDetailData(
val files: List<Models.SkillFile>, val files: List<Models.SkillFile>,
) )
private data class PilotAssigneeOption( private data class PilotMentionOption(
val id: String?, val id: String?,
val type: String?, val type: String?,
val name: String, val name: String,
) )
private fun pilotMentionOptions(
includeEmpty: Boolean,
emptyLabel: String,
currentUser: Models.User?,
members: List<Models.Member>,
agents: List<Models.Agent>,
squads: List<Models.Squad>,
query: String = "",
): List<PilotMentionOption> {
val normalizedQuery = query.trim().lowercase()
return Models.issueAssignees(includeEmpty, emptyLabel, currentUser, members, agents, squads)
.filter { option ->
normalizedQuery.isBlank() ||
option.name.lowercase().contains(normalizedQuery) ||
option.id.orEmpty().lowercase().contains(normalizedQuery)
}
.map { PilotMentionOption(it.id, it.type, it.name) }
}
private fun pilotIssueFormAssigneeOptions(
emptyLabel: String,
currentUser: Models.User?,
members: List<Models.Member>,
agents: List<Models.Agent>,
squads: List<Models.Squad>,
): List<PilotMentionOption> {
return Models.issueFormAssigneeOptions(emptyLabel, currentUser, members, agents, squads)
.map { PilotMentionOption(it.id, it.type, it.name) }
}
private fun pilotMentionTypeLabel(type: String?, zh: Boolean): String = when (type) {
"member" -> if (zh) "" else "Person"
"agent" -> "Agent"
"squad" -> if (zh) "小队" else "Squad"
else -> if (zh) "" else "None"
}
private fun pilotMentionTypeColor(type: String?): Color = when (type) {
"member" -> Color(0xFF16A34A)
"agent" -> MulticaColors.Accent
"squad" -> MulticaColors.Warning
else -> MulticaColors.Muted
}
private val PILOT_REACTION_EMOJIS = listOf("👍", "❤️", "👀", "🎉") private val PILOT_REACTION_EMOJIS = listOf("👍", "❤️", "👀", "🎉")
private const val PILOT_INBOX_PAGE_SIZE = 20 private const val PILOT_INBOX_PAGE_SIZE = 20
@@ -2000,6 +2067,7 @@ private fun PilotTabContent(
workspaceId = session.workspace.id, workspaceId = session.workspace.id,
workspace = session.workspace, workspace = session.workspace,
projects = state.projects, projects = state.projects,
currentUser = session.user,
zh = zh, zh = zh,
onProjectClick = onProjectClick, onProjectClick = onProjectClick,
onRefresh = onRetry, onRefresh = onRetry,
@@ -6773,6 +6841,8 @@ private fun IssueFormMenuRow(
items = options.map { option -> items = options.map { option ->
MulticaCupertinoActionSheetItem( MulticaCupertinoActionSheetItem(
title = option.label, title = option.label,
badgeText = option.badgeText,
badgeColor = option.badgeColor,
contentDescription = "$contentDescription ${option.label}", contentDescription = "$contentDescription ${option.label}",
selected = option.selected, selected = option.selected,
onClick = option.onClick, onClick = option.onClick,
@@ -6786,6 +6856,8 @@ private fun IssueFormMenuRow(
private data class IssueFormMenuOption( private data class IssueFormMenuOption(
val label: String, val label: String,
val badgeText: String? = null,
val badgeColor: Color = MulticaColors.Accent,
val selected: Boolean, val selected: Boolean,
val onClick: () -> Unit, val onClick: () -> Unit,
) )
@@ -6825,7 +6897,7 @@ private fun PilotIssueForm(
projects = api.projects(session.workspace.id, 500, 0).items, projects = api.projects(session.workspace.id, 500, 0).items,
members = api.members(session.workspace.id), members = api.members(session.workspace.id),
agents = api.agents(session.workspace.id), agents = api.agents(session.workspace.id),
squads = runCatching { api.squads(session.workspace.id) }.getOrElse { emptyList() }, squads = api.squads(session.workspace.id),
) )
} }
} }
@@ -6840,14 +6912,13 @@ private fun PilotIssueForm(
) )
else -> { else -> {
val options = loaded.getOrThrow() val options = loaded.getOrThrow()
val assignees = Models.issueAssignees( val assignees = pilotIssueFormAssigneeOptions(
true,
if (zh) "未分配" else "Unassigned", if (zh) "未分配" else "Unassigned",
session.user, session.user,
options.members, options.members,
options.agents, options.agents,
options.squads, options.squads,
).map { PilotAssigneeOption(it.id, it.type, it.name) } )
var title by remember(issue?.id) { mutableStateOf(issue?.title.orEmpty()) } var title by remember(issue?.id) { mutableStateOf(issue?.title.orEmpty()) }
var description by remember(issue?.id) { mutableStateOf(issue?.description.orEmpty()) } var description by remember(issue?.id) { mutableStateOf(issue?.description.orEmpty()) }
var dueDate by remember(issue?.id) { mutableStateOf(issue?.dueDate.orEmpty()) } var dueDate by remember(issue?.id) { mutableStateOf(issue?.dueDate.orEmpty()) }
@@ -6947,6 +7018,7 @@ private fun PilotIssueForm(
val status = Models.STATUS_VALUES[statusIndex] val status = Models.STATUS_VALUES[statusIndex]
val priority = Models.PRIORITY_VALUES[priorityIndex] val priority = Models.PRIORITY_VALUES[priorityIndex]
val selectedAssignee = assignees[assigneeIndex] val selectedAssignee = assignees[assigneeIndex]
val teamId = if (!editing && selectedAssignee.type == "squad") selectedAssignee.id else null
val assignee = if (selectedAssignee.id == null) null else { val assignee = if (selectedAssignee.id == null) null else {
Models.Assignee(selectedAssignee.id, selectedAssignee.type, selectedAssignee.name) Models.Assignee(selectedAssignee.id, selectedAssignee.type, selectedAssignee.name)
} }
@@ -6956,7 +7028,7 @@ private fun PilotIssueForm(
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
runCatching { runCatching {
val saved = if (issue == null) { val saved = if (issue == null) {
api.createIssue(titleText, description, session.workspace.id, projectId, status, priority, assignee, dueDateText, parentIssueId) api.createIssue(titleText, description, session.workspace.id, projectId, teamId, status, priority, assignee, dueDateText, parentIssueId)
} else { } else {
api.updateIssue(issue, titleText, description, projectId, status, priority, assignee, dueDateText) api.updateIssue(issue, titleText, description, projectId, status, priority, assignee, dueDateText)
} }
@@ -7193,6 +7265,8 @@ private fun PilotIssueForm(
options = assignees.mapIndexed { index, assignee -> options = assignees.mapIndexed { index, assignee ->
IssueFormMenuOption( IssueFormMenuOption(
label = assignee.name, label = assignee.name,
badgeText = pilotMentionTypeLabel(assignee.type, zh),
badgeColor = pilotMentionTypeColor(assignee.type),
selected = assigneeIndex == index, selected = assigneeIndex == index,
onClick = { assigneeIndex = index }, onClick = { assigneeIndex = index },
) )
@@ -9309,6 +9383,283 @@ private fun IssueAttachmentsWebPanel(
} }
} }
@Composable
private fun PilotAttachmentPreview(
api: ApiClient,
attachment: Models.Attachment,
zh: Boolean,
onBack: () -> Unit,
) {
val context = LocalContext.current
val kind = remember(attachment.contentType, attachment.filename) {
AttachmentPreview.kindFor(attachment.contentType, attachment.filename)
}
val target = remember(attachment.downloadUrl, attachment.url) {
absoluteAttachmentUrl(clean(attachment.downloadUrl).ifBlank { clean(attachment.url) })
}
var state by remember(attachment.id) { mutableStateOf<Result<String>?>(null) }
var refresh by remember(attachment.id) { mutableIntStateOf(0) }
BackHandler(onBack = onBack)
LaunchedEffect(attachment.id, target, refresh) {
state = null
state = withContext(Dispatchers.IO) {
runCatching { api.downloadAttachmentText(target) }
}
}
Box(
modifier = Modifier
.fillMaxSize()
.semantics { contentDescription = "Attachment Preview Sheet ${attachment.id}" },
contentAlignment = Alignment.BottomCenter,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.32f))
.clickable(onClick = onBack),
)
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.97f)
.statusBarsPadding(),
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
color = MulticaColors.Background,
tonalElevation = 0.dp,
shadowElevation = 12.dp,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(start = 12.dp, top = 4.dp, end = 12.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(28.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Spacer(modifier = Modifier.width(36.dp))
Box(
modifier = Modifier
.width(42.dp)
.height(4.dp)
.clip(RoundedCornerShape(999.dp))
.background(MulticaColors.Border),
)
MulticaIconPillButton(
icon = Icons.Outlined.Close,
contentDescription = if (zh) "关闭附件预览" else "Close attachment preview",
onClick = onBack,
tone = MulticaButtonTone.Ghost,
modifier = Modifier.size(28.dp),
)
}
when {
target.isBlank() -> MulticaErrorState(
message = if (zh) "附件没有可预览链接" else "Attachment has no preview URL",
onRetry = onBack,
fullScreen = false,
)
state == null -> Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
CupertinoActivityIndicator(modifier = Modifier.size(24.dp))
}
state?.isFailure == true -> MulticaErrorState(
message = "${if (zh) "附件加载失败" else "Attachment load failed"}\n${state?.exceptionOrNull()?.message.orEmpty()}",
onRetry = { refresh++ },
fullScreen = false,
)
else -> {
val body = state?.getOrNull().orEmpty()
when (kind) {
AttachmentPreview.Kind.MARKDOWN -> AttachmentMarkdownPreview(body)
AttachmentPreview.Kind.HTML -> AttachmentHtmlPreview(body, context)
AttachmentPreview.Kind.XML -> AttachmentHtmlPreview(AttachmentPreview.xmlPreviewDocument(body), context)
AttachmentPreview.Kind.DOWNLOAD -> MulticaErrorState(
message = if (zh) "该附件类型不支持内置预览" else "This attachment type does not support built-in preview",
onRetry = onBack,
fullScreen = false,
)
}
}
}
}
}
}
}
@Composable
private fun AttachmentMarkdownPreview(markdown: String) {
SelectionContainer {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
factory = { context ->
LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, 0, 0, 24)
}
},
update = { view ->
view.removeAllViews()
MarkdownRenderer.render(
view.context,
view,
markdown,
MulticaColors.Text.toArgb(),
MulticaColors.Muted.toArgb(),
MulticaColors.Border.toArgb(),
)
},
)
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun ColumnScope.AttachmentHtmlPreview(html: String, context: Context) {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
factory = { viewContext ->
WebView(viewContext).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = false
settings.databaseEnabled = false
settings.cacheMode = WebSettings.LOAD_NO_CACHE
settings.allowFileAccess = false
settings.allowContentAccess = false
settings.allowFileAccessFromFileURLs = false
settings.allowUniversalAccessFromFileURLs = false
clearCache(true)
clearHistory()
webViewClient = AttachmentPreviewWebViewClient(context)
}
},
update = { webView ->
webView.loadDataWithBaseURL(AttachmentPreview.BASE_URL, html, "text/html", "UTF-8", null)
},
onRelease = { webView ->
webView.stopLoading()
webView.clearHistory()
webView.clearCache(true)
webView.destroy()
},
)
}
private class AttachmentPreviewWebViewClient(
private val context: Context,
) : WebViewClient() {
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
return handleNavigation(url)
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
return handleNavigation(request.url?.toString().orEmpty())
}
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
return null
}
private fun handleNavigation(url: String): Boolean {
if (AttachmentPreview.isWebViewInternalDocumentUrl(url)) return false
if (AttachmentPreview.isLocalPreviewUrl(url)) return false
openExternalUrl(context, url)
return true
}
}
@Composable
private fun PilotAttachmentMarkdownPreviewFixture() {
Column(
modifier = Modifier
.fillMaxSize()
.background(MulticaColors.Background)
.statusBarsPadding()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
PilotPageHeader(title = "Markdown Attachment Preview")
AttachmentMarkdownPreview(
"""
# Markdown Attachment
This attachment renders **inside Android** with the existing Markdown renderer.
| Feature | Status |
| --- | --- |
| Tables | Ready |
| Links | [Multica](https://app.multica.ai) |
""".trimIndent(),
)
}
}
@Composable
private fun PilotAttachmentHtmlPreviewFixture() {
Column(
modifier = Modifier
.fillMaxSize()
.background(MulticaColors.Background)
.statusBarsPadding()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
PilotPageHeader(title = "HTML Attachment Preview")
AttachmentHtmlPreview(
"""
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font: 16px -apple-system, BlinkMacSystemFont, sans-serif; padding: 16px; color: #111; }
button { border: 1px solid #d1d5db; border-radius: 999px; padding: 10px 14px; background: white; }
button.active { background: #0A58FF; color: white; }
.panel { display: none; margin-top: 16px; padding: 16px; border: 1px solid #e5e7eb; border-radius: 16px; }
.panel.active { display: block; }
</style>
</head>
<body>
<h1>HTML Attachment</h1>
<button id="tab-one" class="active" onclick="showTab('one')">Tab One</button>
<button id="tab-two" onclick="showTab('two')">Tab Two</button>
<a href="#local-anchor" id="anchor-link">Jump to anchor</a>
<a href="https://example.com/" id="external-link">External link</a>
<section id="one" class="panel active">First tab content</section>
<section id="two" class="panel">Second tab content</section>
<p id="local-anchor">Local anchor target</p>
<script>
function showTab(id) {
document.querySelectorAll('button').forEach(function(button) { button.classList.remove('active'); });
document.querySelectorAll('.panel').forEach(function(panel) { panel.classList.remove('active'); });
document.getElementById('tab-' + id).classList.add('active');
document.getElementById(id).classList.add('active');
}
</script>
</body>
</html>
""".trimIndent(),
LocalContext.current,
)
}
}
private fun shouldCollapseIssueComment(content: String): Boolean { private fun shouldCollapseIssueComment(content: String): Boolean {
if (content.contains("```")) return false if (content.contains("```")) return false
if (content.lines().any { it.trim().startsWith("|") && it.trim().endsWith("|") }) return false if (content.lines().any { it.trim().startsWith("|") && it.trim().endsWith("|") }) return false
@@ -9368,6 +9719,7 @@ private fun PilotIssueDetail(
var uploadingAttachment by remember(issueId) { mutableStateOf(false) } var uploadingAttachment by remember(issueId) { mutableStateOf(false) }
var deletingAttachmentId by remember(issueId) { mutableStateOf<String?>(null) } var deletingAttachmentId by remember(issueId) { mutableStateOf<String?>(null) }
var attachmentMessage by remember(issueId) { mutableStateOf<String?>(null) } var attachmentMessage by remember(issueId) { mutableStateOf<String?>(null) }
var previewAttachment by remember(issueId) { mutableStateOf<Models.Attachment?>(null) }
var confirmingDeleteIssue by remember(issueId) { mutableStateOf(false) } var confirmingDeleteIssue by remember(issueId) { mutableStateOf(false) }
var deletingIssue by remember(issueId) { mutableStateOf(false) } var deletingIssue by remember(issueId) { mutableStateOf(false) }
var issueMutationMessage by remember(issueId) { mutableStateOf<String?>(null) } var issueMutationMessage by remember(issueId) { mutableStateOf<String?>(null) }
@@ -9404,7 +9756,6 @@ private fun PilotIssueDetail(
) )
return return
} }
LaunchedEffect(issueId, workspaceId, highlightCommentId, detailRefresh) { LaunchedEffect(issueId, workspaceId, highlightCommentId, detailRefresh) {
state = null state = null
val coreResult = withContext(Dispatchers.IO) { val coreResult = withContext(Dispatchers.IO) {
@@ -9650,11 +10001,15 @@ private fun PilotIssueDetail(
} }
fun openAttachment(attachment: Models.Attachment) { fun openAttachment(attachment: Models.Attachment) {
val target = clean(attachment.downloadUrl).ifBlank { clean(attachment.url) } val target = absoluteAttachmentUrl(clean(attachment.downloadUrl).ifBlank { clean(attachment.url) })
if (target.isBlank()) { if (target.isBlank()) {
attachmentMessage = if (zh) "附件没有可打开的链接" else "Attachment has no openable URL" attachmentMessage = if (zh) "附件没有可打开的链接" else "Attachment has no openable URL"
return return
} }
if (AttachmentPreview.kindFor(attachment.contentType, attachment.filename) != AttachmentPreview.Kind.DOWNLOAD) {
previewAttachment = attachment
return
}
val result = runCatching { val result = runCatching {
context.startActivity( context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(target)) Intent(Intent.ACTION_VIEW, Uri.parse(target))
@@ -10760,6 +11115,14 @@ if (replyingThread != null) {
}, },
) )
} }
previewAttachment?.let { attachment ->
PilotAttachmentPreview(
api = api,
attachment = attachment,
zh = zh,
onBack = { previewAttachment = null },
)
}
} }
} }
} }
@@ -10833,34 +11196,7 @@ private fun IssueCommentReplyBox(
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val replyActionModifier = Modifier.size(42.dp) val replyActionModifier = Modifier.size(42.dp)
val mentionItems = remember(agents, members, squads, mentionQuery) { val mentionItems = remember(agents, members, squads, mentionQuery) {
val query = mentionQuery.trim().lowercase() pilotMentionOptions(false, "", null, members, agents.filter { it.archivedAt.isBlank() }, squads, mentionQuery)
val agentItems = agents
.filter { it.archivedAt.isBlank() }
.filter { agent ->
query.isBlank() ||
agent.name.lowercase().contains(query) ||
agent.description.lowercase().contains(query) ||
agent.id.lowercase().contains(query)
}
.sortedBy { it.name.lowercase() }
.map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "agent") }
val memberItems = members
.filter { member ->
query.isBlank() ||
member.displayName.lowercase().contains(query) ||
member.email.lowercase().contains(query)
}
.sortedBy { it.displayName.lowercase() }
.map { Triple(it.id, it.displayName, "member") }
val squadItems = squads
.filter { squad ->
query.isBlank() ||
squad.name.lowercase().contains(query) ||
squad.description.lowercase().contains(query)
}
.sortedBy { it.name.lowercase() }
.map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "squad") }
agentItems + memberItems + squadItems
} }
LaunchedEffect(parentId) { LaunchedEffect(parentId) {
delay(180) delay(180)
@@ -11002,12 +11338,15 @@ Column(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
mentionItems.forEach { (id, name, type) -> mentionItems.forEach { item ->
IssueMentionUnifiedRow( IssueMentionUnifiedRow(
name = name, name = item.name,
mentionType = type, mentionType = item.type.orEmpty(),
contentDescription = "Issue Comment Reply Mention $type Row $id", zh = zh,
contentDescription = "Issue Comment Reply Mention ${item.type} Row ${item.id}",
onClick = { onClick = {
val name = item.name
val id = item.id.orEmpty()
val insertion = AgentMentionMarkdown.displayText(name, id) val insertion = AgentMentionMarkdown.displayText(name, id)
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " " val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
onDraftChange(draft + separator + insertion + " ") onDraftChange(draft + separator + insertion + " ")
@@ -11098,6 +11437,7 @@ private fun IssueMentionAgentWebRow(
private fun IssueMentionUnifiedRow( private fun IssueMentionUnifiedRow(
name: String, name: String,
mentionType: String, mentionType: String,
zh: Boolean,
contentDescription: String, contentDescription: String,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
@@ -11106,21 +11446,13 @@ private fun IssueMentionUnifiedRow(
"squad" -> name.take(1).uppercase().ifBlank { "S" } "squad" -> name.take(1).uppercase().ifBlank { "S" }
else -> name.take(1).uppercase().ifBlank { "A" } else -> name.take(1).uppercase().ifBlank { "A" }
} }
val iconColor = when (mentionType) { val iconColor = pilotMentionTypeColor(mentionType)
"member" -> MulticaColors.Text
"squad" -> MulticaColors.Accent
else -> MulticaColors.Accent
}
val iconBg = when (mentionType) { val iconBg = when (mentionType) {
"member" -> MulticaColors.AccentSoft.copy(alpha = 0.12f) "member" -> MulticaColors.AccentSoft.copy(alpha = 0.12f)
"squad" -> MulticaColors.AccentSoft.copy(alpha = 0.36f) "squad" -> MulticaColors.AccentSoft.copy(alpha = 0.36f)
else -> MulticaColors.AccentSoft.copy(alpha = 0.24f) else -> MulticaColors.AccentSoft.copy(alpha = 0.24f)
} }
val typeLabel = when (mentionType) { val typeLabel = pilotMentionTypeLabel(mentionType, zh)
"member" -> "member"
"squad" -> "squad"
else -> "agent"
}
val mentionMarkdown = when (mentionType) { val mentionMarkdown = when (mentionType) {
"member" -> AgentMentionMarkdown.memberMarkdown(name, "") "member" -> AgentMentionMarkdown.memberMarkdown(name, "")
"squad" -> AgentMentionMarkdown.squadMarkdown(name, "") "squad" -> AgentMentionMarkdown.squadMarkdown(name, "")
@@ -11217,34 +11549,7 @@ private fun IssueCommentInputBar(
configuration.screenHeightDp, configuration.screenHeightDp,
).dp ).dp
val mentionItems = remember(agents, members, squads, mentionQuery) { val mentionItems = remember(agents, members, squads, mentionQuery) {
val query = mentionQuery.trim().lowercase() pilotMentionOptions(false, "", null, members, agents.filter { it.archivedAt.isBlank() }, squads, mentionQuery)
val agentItems = agents
.filter { it.archivedAt.isBlank() }
.filter { agent ->
query.isBlank() ||
agent.name.lowercase().contains(query) ||
agent.description.lowercase().contains(query) ||
agent.id.lowercase().contains(query)
}
.sortedBy { it.name.lowercase() }
.map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "agent") }
val memberItems = members
.filter { member ->
query.isBlank() ||
member.displayName.lowercase().contains(query) ||
member.email.lowercase().contains(query)
}
.sortedBy { it.displayName.lowercase() }
.map { Triple(it.id, it.displayName, "member") }
val squadItems = squads
.filter { squad ->
query.isBlank() ||
squad.name.lowercase().contains(query) ||
squad.description.lowercase().contains(query)
}
.sortedBy { it.name.lowercase() }
.map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "squad") }
agentItems + memberItems + squadItems
} }
Surface( Surface(
modifier = Modifier modifier = Modifier
@@ -11410,12 +11715,15 @@ Column(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
mentionItems.forEach { (id, name, type) -> mentionItems.forEach { item ->
IssueMentionUnifiedRow( IssueMentionUnifiedRow(
name = name, name = item.name,
mentionType = type, mentionType = item.type.orEmpty(),
contentDescription = "Issue Comment Mention $type Row $id", zh = zh,
contentDescription = "Issue Comment Mention ${item.type} Row ${item.id}",
onClick = { onClick = {
val name = item.name
val id = item.id.orEmpty()
val insertion = AgentMentionMarkdown.displayText(name, id) val insertion = AgentMentionMarkdown.displayText(name, id)
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " " val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
onDraftChange(draft + separator + insertion + " ") onDraftChange(draft + separator + insertion + " ")
@@ -12056,6 +12364,15 @@ private fun absoluteMarkdownImageUrl(url: String): String {
} }
} }
private fun absoluteAttachmentUrl(url: String): String {
val value = clean(url)
return if (value.startsWith("/")) {
BuildConfig.MULTICA_API_BASE_URL.trimEnd('/') + value
} else {
value
}
}
private fun openExternalUrl(context: Context, url: String) { private fun openExternalUrl(context: Context, url: String) {
val target = clean(url) val target = clean(url)
if (target.isBlank()) return if (target.isBlank()) return
@@ -12978,6 +13295,7 @@ private fun PilotProjects(
workspaceId: String, workspaceId: String,
workspace: Models.Workspace, workspace: Models.Workspace,
projects: List<Models.Project>, projects: List<Models.Project>,
currentUser: Models.User?,
zh: Boolean, zh: Boolean,
onProjectClick: (Models.Project) -> Unit, onProjectClick: (Models.Project) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
@@ -13000,6 +13318,7 @@ private fun PilotProjects(
var resourceUrlsText by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf("") } var resourceUrlsText by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf("") }
var projectMembers by remember(workspaceId) { mutableStateOf<List<Models.Member>>(emptyList()) } var projectMembers by remember(workspaceId) { mutableStateOf<List<Models.Member>>(emptyList()) }
var projectAgents by remember(workspaceId) { mutableStateOf<List<Models.Agent>>(emptyList()) } var projectAgents by remember(workspaceId) { mutableStateOf<List<Models.Agent>>(emptyList()) }
var projectSquads by remember(workspaceId) { mutableStateOf<List<Models.Squad>>(emptyList()) }
var loadingProjectOptions by remember(workspaceId) { mutableStateOf(false) } var loadingProjectOptions by remember(workspaceId) { mutableStateOf(false) }
var saving by remember(workspaceId) { mutableStateOf(false) } var saving by remember(workspaceId) { mutableStateOf(false) }
var pendingDelete by remember(workspaceId) { mutableStateOf<Models.Project?>(null) } var pendingDelete by remember(workspaceId) { mutableStateOf<Models.Project?>(null) }
@@ -13099,7 +13418,7 @@ private fun PilotProjects(
} }
LaunchedEffect(formOpen, workspaceId) { LaunchedEffect(formOpen, workspaceId) {
if (!formOpen || projectMembers.isNotEmpty() || projectAgents.isNotEmpty() || loadingProjectOptions) return@LaunchedEffect if (!formOpen || projectMembers.isNotEmpty() || projectAgents.isNotEmpty() || projectSquads.isNotEmpty() || loadingProjectOptions) return@LaunchedEffect
loadingProjectOptions = true loadingProjectOptions = true
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { api.members(workspaceId) to api.agents(workspaceId) } runCatching { api.members(workspaceId) to api.agents(workspaceId) }
@@ -13109,15 +13428,26 @@ private fun PilotProjects(
}.onFailure { }.onFailure {
resultText = "${if (zh) "项目选项加载失败" else "Project options failed"}: ${it.message ?: it.toString()}" resultText = "${if (zh) "项目选项加载失败" else "Project options failed"}: ${it.message ?: it.toString()}"
} }
val squads = withContext(Dispatchers.IO) { runCatching { api.squads(workspaceId) }.getOrDefault(emptyList()) }
projectSquads = squads
loadingProjectOptions = false loadingProjectOptions = false
} }
val selectedLeadLabel = remember(leadType, leadId, projectMembers, projectAgents, zh) { val projectLeadOptions = remember(currentUser, projectMembers, projectAgents, projectSquads, zh) {
when (leadType) { pilotIssueFormAssigneeOptions(
"member" -> projectMembers.firstOrNull { it.userId == leadId || it.id == leadId }?.let { member -> if (zh) "无负责人" else "No Lead",
member.displayName.ifBlank { member.email } currentUser,
} ?: Models.shortId(leadId) projectMembers,
"agent" -> projectAgents.firstOrNull { it.id == leadId }?.name ?: Models.shortId(leadId) projectAgents,
projectSquads,
)
}
val selectedLeadLabel = remember(leadType, leadId, projectLeadOptions, zh) {
projectLeadOptions.firstOrNull { option ->
option.id == leadId && (option.type == leadType || leadType.isBlank())
}?.name ?: when (leadType) {
"squad", "agent", "member" -> Models.shortId(leadId)
else -> if (zh) "无负责人" else "No Lead" else -> if (zh) "无负责人" else "No Lead"
} }
} }
@@ -13267,39 +13597,24 @@ private fun PilotProjects(
eyebrow = if (zh) "负责人" else "Lead", eyebrow = if (zh) "负责人" else "Lead",
title = selectedLeadLabel, title = selectedLeadLabel,
subtitle = if (loadingProjectOptions) { subtitle = if (loadingProjectOptions) {
if (zh) "正在加载成员和 Agent" else "Loading members and agents" if (zh) "正在加载人、Agent 和小队" else "Loading people, agents, and squads"
} else { } else {
null null
}, },
contentDescription = "Project Lead Picker", contentDescription = "Project Lead Picker",
options = listOf( options = projectLeadOptions.map { lead ->
IssueFormMenuOption( IssueFormMenuOption(
label = if (zh) "无负责人" else "No Lead", label = lead.name,
selected = leadType.isBlank() || leadId.isBlank(), badgeText = pilotMentionTypeLabel(lead.type, zh),
onClick = { badgeColor = pilotMentionTypeColor(lead.type),
leadType = "" selected = if (lead.id == null) {
leadId = "" leadType.isBlank() || leadId.isBlank()
} else {
leadType == lead.type && leadId == lead.id
}, },
)
) + projectMembers.map { member ->
val assigneeId = member.userId.ifBlank { member.id }
IssueFormMenuOption(
label = listOf(member.displayName.ifBlank { member.email }, if (zh) "成员" else "Member")
.filter { it.isNotBlank() }
.joinToString(" · "),
selected = leadType == "member" && (leadId == assigneeId || leadId == member.id),
onClick = { onClick = {
leadType = "member" leadType = lead.type.orEmpty()
leadId = assigneeId leadId = lead.id.orEmpty()
},
)
} + projectAgents.map { agent ->
IssueFormMenuOption(
label = listOf(agent.name, "Agent").filter { it.isNotBlank() }.joinToString(" · "),
selected = leadType == "agent" && leadId == agent.id,
onClick = {
leadType = "agent"
leadId = agent.id
}, },
) )
}, },
@@ -0,0 +1,28 @@
package ai.multica.app;
import org.json.JSONObject;
final class IssuePayloads {
private IssuePayloads() {
}
static JSONObject createIssue(String title, String description, String workspaceId, String projectId,
String teamId, String status, String priority, Models.Assignee assignee,
String dueDate, String parentIssueId) throws Exception {
JSONObject body = new JSONObject();
body.put("title", title);
body.put("description", description == null || description.isEmpty() ? JSONObject.NULL : description);
body.put("workspace_id", workspaceId);
if (projectId != null) body.put("project_id", projectId);
if (teamId != null && !teamId.trim().isEmpty()) body.put("team_id", teamId.trim());
if (parentIssueId != null && !parentIssueId.isEmpty()) body.put("parent_issue_id", parentIssueId);
if (status != null) body.put("status", status);
if (priority != null) body.put("priority", priority);
if (dueDate != null && !dueDate.isEmpty()) body.put("due_date", dueDate); else body.put("due_date", JSONObject.NULL);
if (assignee != null && assignee.id != null) {
body.put("assignee_id", assignee.id);
body.put("assignee_type", assignee.type);
}
return body;
}
}
@@ -810,6 +810,7 @@ public final class MainActivity extends Activity {
String statusValue = Models.STATUS_VALUES[Math.max(0, status.getSelectedItemPosition())]; String statusValue = Models.STATUS_VALUES[Math.max(0, status.getSelectedItemPosition())];
String priorityValue = Models.PRIORITY_VALUES[Math.max(0, priority.getSelectedItemPosition())]; String priorityValue = Models.PRIORITY_VALUES[Math.max(0, priority.getSelectedItemPosition())];
Models.Assignee selectedAssignee = assigneeAt(assignee.getSelectedItemPosition(), true); Models.Assignee selectedAssignee = assigneeAt(assignee.getSelectedItemPosition(), true);
String teamId = selectedAssignee != null && "squad".equals(selectedAssignee.type) ? selectedAssignee.id : null;
if (demoMode) { if (demoMode) {
upsertDemoIssue( upsertDemoIssue(
editing ? issue.id : "demo-issue-" + (demoIssues.size() + 1), editing ? issue.id : "demo-issue-" + (demoIssues.size() + 1),
@@ -828,7 +829,7 @@ public final class MainActivity extends Activity {
return api.updateIssue(issue, titleText, desc.getText().toString(), projectId, statusValue, priorityValue, selectedAssignee); return api.updateIssue(issue, titleText, desc.getText().toString(), projectId, statusValue, priorityValue, selectedAssignee);
} }
if (currentWorkspace == null) throw new IllegalStateException(t("workspaceRequired")); if (currentWorkspace == null) throw new IllegalStateException(t("workspaceRequired"));
return api.createIssue(titleText, desc.getText().toString(), currentWorkspace.id, projectId, statusValue, priorityValue, selectedAssignee); return api.createIssue(titleText, desc.getText().toString(), currentWorkspace.id, projectId, teamId, statusValue, priorityValue, selectedAssignee, null, null);
}, saved -> afterSave.run(), error -> toast(t("saveFailed") + ": " + error.getMessage())); }, saved -> afterSave.run(), error -> toast(t("saveFailed") + ": " + error.getMessage()));
}).show(); }).show();
} }
@@ -1861,7 +1862,9 @@ public final class MainActivity extends Activity {
private List<Models.Assignee> assignees(boolean includeEmpty) { private List<Models.Assignee> assignees(boolean includeEmpty) {
if (memberCache.isEmpty()) memberCache = safeMembers(); if (memberCache.isEmpty()) memberCache = safeMembers();
if (agentCache.isEmpty()) agentCache = safeAgents(); if (agentCache.isEmpty()) agentCache = safeAgents();
return Models.issueAssignees(includeEmpty, t("unassigned"), currentUser, memberCache, agentCache, squadCache); if (squadCache.isEmpty()) squadCache = safeSquads();
if (includeEmpty) return Models.issueFormAssigneeOptions(t("unassigned"), currentUser, memberCache, agentCache, squadCache);
return Models.issueAssignees(false, t("unassigned"), currentUser, memberCache, agentCache, squadCache);
} }
private String assigneeName(String id, String type) { private String assigneeName(String id, String type) {
@@ -230,6 +230,31 @@ final class Models {
return list; return list;
} }
static List<Assignee> issueFormAssigneeOptions(String emptyLabel, User currentUser,
List<Member> members, List<Agent> agents, List<Squad> squads) {
List<Assignee> list = new ArrayList<>();
list.add(new Assignee(null, null, emptyLabel));
for (Squad squad : squads) {
if (squad.id == null || squad.id.isEmpty()) continue;
list.add(new Assignee(squad.id, "squad", squad.name));
}
for (Agent agent : agents) {
if (agent.id == null || agent.id.isEmpty()) continue;
list.add(new Assignee(agent.id, "agent", agent.name));
}
List<String> seenMemberIds = new ArrayList<>();
if (currentUser != null && currentUser.id != null && !currentUser.id.isEmpty()) {
list.add(new Assignee(currentUser.id, "member", currentUser.name));
seenMemberIds.add(currentUser.id);
}
for (Member member : members) {
if (member.id == null || member.id.isEmpty() || seenMemberIds.contains(member.id)) continue;
list.add(new Assignee(member.id, "member", member.displayName));
seenMemberIds.add(member.id);
}
return list;
}
static final class Page<T> { static final class Page<T> {
final List<T> items; final List<T> items;
final boolean hasMore; final boolean hasMore;
@@ -1021,6 +1021,8 @@ data class MulticaCupertinoActionSheetItem(
val title: String, val title: String,
val subtitle: String? = null, val subtitle: String? = null,
val icon: ImageVector? = null, val icon: ImageVector? = null,
val badgeText: String? = null,
val badgeColor: Color = MulticaColors.Accent,
val contentDescription: String? = null, val contentDescription: String? = null,
val selected: Boolean = false, val selected: Boolean = false,
val destructive: Boolean = false, val destructive: Boolean = false,
@@ -1056,6 +1058,10 @@ fun MulticaCupertinoActionSheet(
) )
}, },
message = { message = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!message.isNullOrBlank()) { if (!message.isNullOrBlank()) {
Text( Text(
text = message, text = message,
@@ -1063,22 +1069,59 @@ fun MulticaCupertinoActionSheet(
color = MulticaColors.TextTertiary, color = MulticaColors.TextTertiary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(modifier = Modifier.height(8.dp))
} }
}, LazyColumn(
buttons = { modifier = Modifier
items.forEach { item -> .fillMaxWidth()
action( .heightIn(max = 420.dp),
) {
items(items.size) { index ->
val item = items[index]
MulticaCupertinoActionSheetItemContent(
item = item,
onClick = { onClick = {
if (!item.enabled) return@MulticaCupertinoActionSheetItemContent
performMulticaTapFeedback(haptic) performMulticaTapFeedback(haptic)
item.onClick() item.onClick()
onDismissRequest() onDismissRequest()
}, },
style = if (item.destructive) AlertActionStyle.Destructive else AlertActionStyle.Default, )
enabled = item.enabled, }
}
}
},
buttons = {
if (!cancelText.isNullOrBlank()) {
action(
onClick = {
performMulticaTapFeedback(haptic)
onDismissRequest()
},
style = AlertActionStyle.Cancel,
enabled = true,
) {
Text(
text = cancelText,
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold),
color = MulticaColors.Accent,
)
}
}
},
)
}
@Composable
private fun MulticaCupertinoActionSheetItemContent(
item: MulticaCupertinoActionSheetItem,
onClick: () -> Unit,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = item.enabled, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 11.dp)
.semantics(mergeDescendants = true) { .semantics(mergeDescendants = true) {
contentDescription = "Multica Cupertino Action Sheet ${item.contentDescription ?: item.title}" contentDescription = "Multica Cupertino Action Sheet ${item.contentDescription ?: item.title}"
}, },
@@ -1093,6 +1136,24 @@ fun MulticaCupertinoActionSheet(
modifier = Modifier.size(16.dp), 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) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
text = item.title, text = item.title,
@@ -1127,26 +1188,6 @@ fun MulticaCupertinoActionSheet(
} }
} }
} }
}
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 @Composable
@OptIn(ExperimentalCupertinoApi::class) @OptIn(ExperimentalCupertinoApi::class)
+1 -1
View File
@@ -12,7 +12,7 @@
<main> <main>
<h1>Multi-Casual Android</h1> <h1>Multi-Casual Android</h1>
<p>Main site is published at <a href="https://multi-casual-android.pages.dev">https://multi-casual-android.pages.dev</a> and <a href="https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/">https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/</a>.</p> <p>Main site is published at <a href="https://multi-casual-android.pages.dev">https://multi-casual-android.pages.dev</a> and <a href="https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/">https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/</a>.</p>
<p>You can also install the signed APK directly from <a href="https://github.com/park0er/multi-casual-android/releases/download/v0.1.2/Multi-Casual-Android-v0.1.2.apk">GitHub Releases</a>.</p> <p>You can also install the signed APK directly from <a href="https://github.com/park0er/multi-casual-android/releases/download/v0.1.4/Multi-Casual-Android-v0.1.4.apk">GitHub Releases</a>.</p>
</main> </main>
<footer> <footer>
<p>Site placeholder published from the public Android source repository.</p> <p>Site placeholder published from the public Android source repository.</p>
-1
View File
@@ -1 +0,0 @@
{"Name":"multi-casual-china","ProjectId":"pages-wu0vu5wirtvg"}
+3 -3
View File
@@ -19,9 +19,9 @@ import promoVideo from "./assets/video/multica-android-promo-draft.mp4";
const release = { const release = {
packageName: "ai.multicasual.app", packageName: "ai.multicasual.app",
sha256: "317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4", sha256: "3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49",
githubUrl: "https://github.com/park0er/multi-casual-android", githubUrl: "https://github.com/park0er/multi-casual-android",
releasesUrl: "https://github.com/park0er/multi-casual-android/releases/download/v0.1.3/Multi-Casual-Android-v0.1.3.apk", releasesUrl: "https://github.com/park0er/multi-casual-android/releases/download/v0.1.5/Multi-Casual-Android-v0.1.5.apk",
iosUrl: "https://github.com/park0er/multi-casual-ios-app", iosUrl: "https://github.com/park0er/multi-casual-ios-app",
multicaUrl: "https://multica.ai", multicaUrl: "https://multica.ai",
apiUrl: "https://api.multica.ai", apiUrl: "https://api.multica.ai",
@@ -640,7 +640,7 @@ export default function App() {
<code>{release.sha256}</code> <code>{release.sha256}</code>
</div> </div>
</div> </div>
<pre><code>{`adb install -r Multi-Casual-Android-v0.1.3.apk`}</code></pre> <pre><code>{`adb install -r Multi-Casual-Android-v0.1.5.apk`}</code></pre>
<div className="cta-row compact"> <div className="cta-row compact">
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.download.latest}</a> <a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.download.latest}</a>
<a className="button secondary" href={release.githubUrl}>{t.download.source}</a> <a className="button secondary" href={release.githubUrl}>{t.download.source}</a>