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.
[![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 | [简体中文](#简体中文)
@@ -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:
- Latest release: https://github.com/park0er/multi-casual-android/releases/latest
- Current signed APK: `Multi-Casual-Android-v0.1.3.apk`
- SHA256: `317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4`
- Current signed APK: `Multi-Casual-Android-v0.1.5.apk`
- SHA256: `3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
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
- 最新 releasehttps://github.com/park0er/multi-casual-android/releases/latest
- 当前签名 APK`Multi-Casual-Android-v0.1.3.apk`
- SHA256`317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4`
- 当前签名 APK`Multi-Casual-Android-v0.1.5.apk`
- SHA256`3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
本地开发命令生成的 debug APK 不用于公开分发。
+2 -2
View File
@@ -51,8 +51,8 @@ android {
applicationId "ai.multicasual.app"
minSdk 24
targetSdk 36
versionCode 3
versionName "0.1.2"
versionCode 5
versionName "0.1.4"
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,
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 (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 createIssue(title, description, workspaceId, projectId, null, status, priority, assignee, dueDate, parentIssueId);
}
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));
}
@@ -526,6 +521,31 @@ final class ApiClient {
return new Models.Attachment(json);
}
String downloadAttachmentText(String url) throws Exception {
if (url == null || url.trim().isEmpty()) return "";
HttpURLConnection conn = (HttpURLConnection) new URL(url.trim()).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(15000);
conn.setReadTimeout(30000);
conn.setRequestProperty("Accept", "text/markdown,text/html,application/xhtml+xml,application/xml,text/xml,text/*,*/*;q=0.8");
conn.setRequestProperty("X-Client-Platform", "android");
conn.setRequestProperty("X-Client-Version", "debug");
String token = authStore.token();
if (token != null && !token.isEmpty() && url.startsWith(BuildConfig.MULTICA_API_BASE_URL)) {
conn.setRequestProperty("Authorization", "Bearer " + token);
}
String cookieHeader = authStore.cloudFrontCookieHeader();
if (cookieHeader != null && !cookieHeader.isEmpty() && url.contains("static.multica.ai")) {
conn.setRequestProperty("Cookie", cookieHeader);
}
int code = conn.getResponseCode();
saveResponseCookies(conn);
InputStream stream = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream();
String text = readTextPreservingLines(stream);
if (code < 200 || code >= 300) throw new ApiException(code, text);
return text;
}
void deleteAttachment(String workspaceId, String attachmentId) throws Exception {
requestObject("DELETE", "/api/attachments/" + encPath(attachmentId),
query(null, "workspace_id", workspaceId), null);
@@ -756,7 +776,7 @@ final class ApiClient {
List<Models.Squad> squads(String workspaceId, boolean includeArchived) throws Exception {
JSONObject query = query(null, "workspace_id", workspaceId);
if (includeArchived) query.put("include_archived", "true");
JSONObject json = requestObject("GET", "/api/squads", query, null);
JSONObject json = requestObject("GET", "/api/squads", query, null, workspaceIdHeaders(workspaceId));
return parseArray(extractArray(json, "squads"), Models.Squad::new);
}
@@ -1511,6 +1531,17 @@ final class ApiClient {
return sb.toString();
}
private static String readTextPreservingLines(InputStream stream) throws Exception {
if (stream == null) return "";
StringBuilder sb = new StringBuilder();
char[] buffer = new char[4096];
try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
int count;
while ((count = reader.read(buffer)) != -1) sb.append(buffer, 0, count);
}
return sb.toString();
}
private static String queryString(JSONObject query) throws Exception {
if (query == null || query.length() == 0) return "";
StringBuilder sb = new StringBuilder("?");
@@ -1562,6 +1593,14 @@ final class ApiClient {
throw new IllegalArgumentException("workspace_id is required. Select a workspace first.");
}
static Map<String, String> workspaceIdHeaders(String workspaceId) {
String id = workspaceId == null ? "" : workspaceId.trim();
if (!id.isEmpty()) {
return Collections.singletonMap("X-Workspace-ID", id);
}
throw new IllegalArgumentException("workspace_id is required. Select a workspace first.");
}
private static boolean isBlankQueryValue(Object value) {
if (value == null || value == JSONObject.NULL) return true;
return String.valueOf(value).trim().isEmpty();
@@ -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
import android.app.Activity
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@@ -12,6 +13,11 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.LinearLayout
import androidx.activity.compose.BackHandler
import androidx.activity.ComponentActivity
@@ -30,6 +36,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
@@ -288,6 +295,22 @@ class ComposePilotActivity : ComponentActivity() {
}
return
}
if (intent.getBooleanExtra("preview_attachment_markdown", false)) {
setContent {
MulticaTheme {
PilotAttachmentMarkdownPreviewFixture()
}
}
return
}
if (intent.getBooleanExtra("preview_attachment_html", false)) {
setContent {
MulticaTheme {
PilotAttachmentHtmlPreviewFixture()
}
}
return
}
if (intent.getBooleanExtra("preview_no_workspace_onboarding", false)) {
val previewCreationSuccess = intent.getBooleanExtra("preview_no_workspace_success", false)
val previewUser = Models.User(JSONObject()
@@ -428,12 +451,56 @@ private data class PilotSkillDetailData(
val files: List<Models.SkillFile>,
)
private data class PilotAssigneeOption(
private data class PilotMentionOption(
val id: String?,
val type: String?,
val name: String,
)
private fun pilotMentionOptions(
includeEmpty: Boolean,
emptyLabel: String,
currentUser: Models.User?,
members: List<Models.Member>,
agents: List<Models.Agent>,
squads: List<Models.Squad>,
query: String = "",
): List<PilotMentionOption> {
val normalizedQuery = query.trim().lowercase()
return Models.issueAssignees(includeEmpty, emptyLabel, currentUser, members, agents, squads)
.filter { option ->
normalizedQuery.isBlank() ||
option.name.lowercase().contains(normalizedQuery) ||
option.id.orEmpty().lowercase().contains(normalizedQuery)
}
.map { PilotMentionOption(it.id, it.type, it.name) }
}
private fun pilotIssueFormAssigneeOptions(
emptyLabel: String,
currentUser: Models.User?,
members: List<Models.Member>,
agents: List<Models.Agent>,
squads: List<Models.Squad>,
): List<PilotMentionOption> {
return Models.issueFormAssigneeOptions(emptyLabel, currentUser, members, agents, squads)
.map { PilotMentionOption(it.id, it.type, it.name) }
}
private fun pilotMentionTypeLabel(type: String?, zh: Boolean): String = when (type) {
"member" -> if (zh) "" else "Person"
"agent" -> "Agent"
"squad" -> if (zh) "小队" else "Squad"
else -> if (zh) "" else "None"
}
private fun pilotMentionTypeColor(type: String?): Color = when (type) {
"member" -> Color(0xFF16A34A)
"agent" -> MulticaColors.Accent
"squad" -> MulticaColors.Warning
else -> MulticaColors.Muted
}
private val PILOT_REACTION_EMOJIS = listOf("👍", "❤️", "👀", "🎉")
private const val PILOT_INBOX_PAGE_SIZE = 20
@@ -2000,6 +2067,7 @@ private fun PilotTabContent(
workspaceId = session.workspace.id,
workspace = session.workspace,
projects = state.projects,
currentUser = session.user,
zh = zh,
onProjectClick = onProjectClick,
onRefresh = onRetry,
@@ -6773,6 +6841,8 @@ private fun IssueFormMenuRow(
items = options.map { option ->
MulticaCupertinoActionSheetItem(
title = option.label,
badgeText = option.badgeText,
badgeColor = option.badgeColor,
contentDescription = "$contentDescription ${option.label}",
selected = option.selected,
onClick = option.onClick,
@@ -6786,6 +6856,8 @@ private fun IssueFormMenuRow(
private data class IssueFormMenuOption(
val label: String,
val badgeText: String? = null,
val badgeColor: Color = MulticaColors.Accent,
val selected: Boolean,
val onClick: () -> Unit,
)
@@ -6825,7 +6897,7 @@ private fun PilotIssueForm(
projects = api.projects(session.workspace.id, 500, 0).items,
members = api.members(session.workspace.id),
agents = api.agents(session.workspace.id),
squads = runCatching { api.squads(session.workspace.id) }.getOrElse { emptyList() },
squads = api.squads(session.workspace.id),
)
}
}
@@ -6840,14 +6912,13 @@ private fun PilotIssueForm(
)
else -> {
val options = loaded.getOrThrow()
val assignees = Models.issueAssignees(
true,
val assignees = pilotIssueFormAssigneeOptions(
if (zh) "未分配" else "Unassigned",
session.user,
options.members,
options.agents,
options.squads,
).map { PilotAssigneeOption(it.id, it.type, it.name) }
)
var title by remember(issue?.id) { mutableStateOf(issue?.title.orEmpty()) }
var description by remember(issue?.id) { mutableStateOf(issue?.description.orEmpty()) }
var dueDate by remember(issue?.id) { mutableStateOf(issue?.dueDate.orEmpty()) }
@@ -6947,6 +7018,7 @@ private fun PilotIssueForm(
val status = Models.STATUS_VALUES[statusIndex]
val priority = Models.PRIORITY_VALUES[priorityIndex]
val selectedAssignee = assignees[assigneeIndex]
val teamId = if (!editing && selectedAssignee.type == "squad") selectedAssignee.id else null
val assignee = if (selectedAssignee.id == null) null else {
Models.Assignee(selectedAssignee.id, selectedAssignee.type, selectedAssignee.name)
}
@@ -6956,7 +7028,7 @@ private fun PilotIssueForm(
val result = withContext(Dispatchers.IO) {
runCatching {
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 {
api.updateIssue(issue, titleText, description, projectId, status, priority, assignee, dueDateText)
}
@@ -7193,6 +7265,8 @@ private fun PilotIssueForm(
options = assignees.mapIndexed { index, assignee ->
IssueFormMenuOption(
label = assignee.name,
badgeText = pilotMentionTypeLabel(assignee.type, zh),
badgeColor = pilotMentionTypeColor(assignee.type),
selected = assigneeIndex == index,
onClick = { assigneeIndex = index },
)
@@ -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 {
if (content.contains("```")) 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 deletingAttachmentId by remember(issueId) { mutableStateOf<String?>(null) }
var attachmentMessage by remember(issueId) { mutableStateOf<String?>(null) }
var previewAttachment by remember(issueId) { mutableStateOf<Models.Attachment?>(null) }
var confirmingDeleteIssue by remember(issueId) { mutableStateOf(false) }
var deletingIssue by remember(issueId) { mutableStateOf(false) }
var issueMutationMessage by remember(issueId) { mutableStateOf<String?>(null) }
@@ -9404,7 +9756,6 @@ private fun PilotIssueDetail(
)
return
}
LaunchedEffect(issueId, workspaceId, highlightCommentId, detailRefresh) {
state = null
val coreResult = withContext(Dispatchers.IO) {
@@ -9650,11 +10001,15 @@ private fun PilotIssueDetail(
}
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()) {
attachmentMessage = if (zh) "附件没有可打开的链接" else "Attachment has no openable URL"
return
}
if (AttachmentPreview.kindFor(attachment.contentType, attachment.filename) != AttachmentPreview.Kind.DOWNLOAD) {
previewAttachment = attachment
return
}
val result = runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(target))
@@ -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 replyActionModifier = Modifier.size(42.dp)
val mentionItems = remember(agents, members, 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
pilotMentionOptions(false, "", null, members, agents.filter { it.archivedAt.isBlank() }, squads, mentionQuery)
}
LaunchedEffect(parentId) {
delay(180)
@@ -11002,12 +11338,15 @@ Column(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
mentionItems.forEach { (id, name, type) ->
mentionItems.forEach { item ->
IssueMentionUnifiedRow(
name = name,
mentionType = type,
contentDescription = "Issue Comment Reply Mention $type Row $id",
name = item.name,
mentionType = item.type.orEmpty(),
zh = zh,
contentDescription = "Issue Comment Reply Mention ${item.type} Row ${item.id}",
onClick = {
val name = item.name
val id = item.id.orEmpty()
val insertion = AgentMentionMarkdown.displayText(name, id)
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
onDraftChange(draft + separator + insertion + " ")
@@ -11098,6 +11437,7 @@ private fun IssueMentionAgentWebRow(
private fun IssueMentionUnifiedRow(
name: String,
mentionType: String,
zh: Boolean,
contentDescription: String,
onClick: () -> Unit,
) {
@@ -11106,21 +11446,13 @@ private fun IssueMentionUnifiedRow(
"squad" -> name.take(1).uppercase().ifBlank { "S" }
else -> name.take(1).uppercase().ifBlank { "A" }
}
val iconColor = when (mentionType) {
"member" -> MulticaColors.Text
"squad" -> MulticaColors.Accent
else -> MulticaColors.Accent
}
val iconColor = pilotMentionTypeColor(mentionType)
val iconBg = when (mentionType) {
"member" -> MulticaColors.AccentSoft.copy(alpha = 0.12f)
"squad" -> MulticaColors.AccentSoft.copy(alpha = 0.36f)
else -> MulticaColors.AccentSoft.copy(alpha = 0.24f)
}
val typeLabel = when (mentionType) {
"member" -> "member"
"squad" -> "squad"
else -> "agent"
}
val typeLabel = pilotMentionTypeLabel(mentionType, zh)
val mentionMarkdown = when (mentionType) {
"member" -> AgentMentionMarkdown.memberMarkdown(name, "")
"squad" -> AgentMentionMarkdown.squadMarkdown(name, "")
@@ -11217,34 +11549,7 @@ private fun IssueCommentInputBar(
configuration.screenHeightDp,
).dp
val mentionItems = remember(agents, members, 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
pilotMentionOptions(false, "", null, members, agents.filter { it.archivedAt.isBlank() }, squads, mentionQuery)
}
Surface(
modifier = Modifier
@@ -11410,12 +11715,15 @@ Column(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
mentionItems.forEach { (id, name, type) ->
mentionItems.forEach { item ->
IssueMentionUnifiedRow(
name = name,
mentionType = type,
contentDescription = "Issue Comment Mention $type Row $id",
name = item.name,
mentionType = item.type.orEmpty(),
zh = zh,
contentDescription = "Issue Comment Mention ${item.type} Row ${item.id}",
onClick = {
val name = item.name
val id = item.id.orEmpty()
val insertion = AgentMentionMarkdown.displayText(name, id)
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
onDraftChange(draft + separator + insertion + " ")
@@ -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) {
val target = clean(url)
if (target.isBlank()) return
@@ -12978,6 +13295,7 @@ private fun PilotProjects(
workspaceId: String,
workspace: Models.Workspace,
projects: List<Models.Project>,
currentUser: Models.User?,
zh: Boolean,
onProjectClick: (Models.Project) -> Unit,
onRefresh: () -> Unit,
@@ -13000,6 +13318,7 @@ private fun PilotProjects(
var resourceUrlsText by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf("") }
var projectMembers by remember(workspaceId) { mutableStateOf<List<Models.Member>>(emptyList()) }
var projectAgents by remember(workspaceId) { mutableStateOf<List<Models.Agent>>(emptyList()) }
var projectSquads by remember(workspaceId) { mutableStateOf<List<Models.Squad>>(emptyList()) }
var loadingProjectOptions by remember(workspaceId) { mutableStateOf(false) }
var saving by remember(workspaceId) { mutableStateOf(false) }
var pendingDelete by remember(workspaceId) { mutableStateOf<Models.Project?>(null) }
@@ -13099,7 +13418,7 @@ private fun PilotProjects(
}
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
withContext(Dispatchers.IO) {
runCatching { api.members(workspaceId) to api.agents(workspaceId) }
@@ -13109,15 +13428,26 @@ private fun PilotProjects(
}.onFailure {
resultText = "${if (zh) "项目选项加载失败" else "Project options failed"}: ${it.message ?: it.toString()}"
}
val squads = withContext(Dispatchers.IO) { runCatching { api.squads(workspaceId) }.getOrDefault(emptyList()) }
projectSquads = squads
loadingProjectOptions = false
}
val selectedLeadLabel = remember(leadType, leadId, projectMembers, projectAgents, zh) {
when (leadType) {
"member" -> projectMembers.firstOrNull { it.userId == leadId || it.id == leadId }?.let { member ->
member.displayName.ifBlank { member.email }
} ?: Models.shortId(leadId)
"agent" -> projectAgents.firstOrNull { it.id == leadId }?.name ?: Models.shortId(leadId)
val projectLeadOptions = remember(currentUser, projectMembers, projectAgents, projectSquads, zh) {
pilotIssueFormAssigneeOptions(
if (zh) "无负责人" else "No Lead",
currentUser,
projectMembers,
projectAgents,
projectSquads,
)
}
val selectedLeadLabel = remember(leadType, leadId, projectLeadOptions, zh) {
projectLeadOptions.firstOrNull { option ->
option.id == leadId && (option.type == leadType || leadType.isBlank())
}?.name ?: when (leadType) {
"squad", "agent", "member" -> Models.shortId(leadId)
else -> if (zh) "无负责人" else "No Lead"
}
}
@@ -13267,39 +13597,24 @@ private fun PilotProjects(
eyebrow = if (zh) "负责人" else "Lead",
title = selectedLeadLabel,
subtitle = if (loadingProjectOptions) {
if (zh) "正在加载成员和 Agent" else "Loading members and agents"
if (zh) "正在加载人、Agent 和小队" else "Loading people, agents, and squads"
} else {
null
},
contentDescription = "Project Lead Picker",
options = listOf(
options = projectLeadOptions.map { lead ->
IssueFormMenuOption(
label = if (zh) "无负责人" else "No Lead",
selected = leadType.isBlank() || leadId.isBlank(),
onClick = {
leadType = ""
leadId = ""
label = lead.name,
badgeText = pilotMentionTypeLabel(lead.type, zh),
badgeColor = pilotMentionTypeColor(lead.type),
selected = if (lead.id == null) {
leadType.isBlank() || leadId.isBlank()
} else {
leadType == lead.type && leadId == lead.id
},
)
) + 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
leadType = lead.type.orEmpty()
leadId = lead.id.orEmpty()
},
)
},
@@ -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 priorityValue = Models.PRIORITY_VALUES[Math.max(0, priority.getSelectedItemPosition())];
Models.Assignee selectedAssignee = assigneeAt(assignee.getSelectedItemPosition(), true);
String teamId = selectedAssignee != null && "squad".equals(selectedAssignee.type) ? selectedAssignee.id : null;
if (demoMode) {
upsertDemoIssue(
editing ? issue.id : "demo-issue-" + (demoIssues.size() + 1),
@@ -828,7 +829,7 @@ public final class MainActivity extends Activity {
return api.updateIssue(issue, titleText, desc.getText().toString(), projectId, statusValue, priorityValue, selectedAssignee);
}
if (currentWorkspace == null) throw new IllegalStateException(t("workspaceRequired"));
return api.createIssue(titleText, desc.getText().toString(), currentWorkspace.id, projectId, 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()));
}).show();
}
@@ -1861,7 +1862,9 @@ public final class MainActivity extends Activity {
private List<Models.Assignee> assignees(boolean includeEmpty) {
if (memberCache.isEmpty()) memberCache = safeMembers();
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) {
@@ -230,6 +230,31 @@ final class Models {
return list;
}
static List<Assignee> issueFormAssigneeOptions(String emptyLabel, User currentUser,
List<Member> members, List<Agent> agents, List<Squad> squads) {
List<Assignee> list = new ArrayList<>();
list.add(new Assignee(null, null, emptyLabel));
for (Squad squad : squads) {
if (squad.id == null || squad.id.isEmpty()) continue;
list.add(new Assignee(squad.id, "squad", squad.name));
}
for (Agent agent : agents) {
if (agent.id == null || agent.id.isEmpty()) continue;
list.add(new Assignee(agent.id, "agent", agent.name));
}
List<String> seenMemberIds = new ArrayList<>();
if (currentUser != null && currentUser.id != null && !currentUser.id.isEmpty()) {
list.add(new Assignee(currentUser.id, "member", currentUser.name));
seenMemberIds.add(currentUser.id);
}
for (Member member : members) {
if (member.id == null || member.id.isEmpty() || seenMemberIds.contains(member.id)) continue;
list.add(new Assignee(member.id, "member", member.displayName));
seenMemberIds.add(member.id);
}
return list;
}
static final class Page<T> {
final List<T> items;
final boolean hasMore;
@@ -1021,6 +1021,8 @@ data class MulticaCupertinoActionSheetItem(
val title: String,
val subtitle: String? = null,
val icon: ImageVector? = null,
val badgeText: String? = null,
val badgeColor: Color = MulticaColors.Accent,
val contentDescription: String? = null,
val selected: Boolean = false,
val destructive: Boolean = false,
@@ -1056,6 +1058,10 @@ fun MulticaCupertinoActionSheet(
)
},
message = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!message.isNullOrBlank()) {
Text(
text = message,
@@ -1063,22 +1069,59 @@ fun MulticaCupertinoActionSheet(
color = MulticaColors.TextTertiary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
}
},
buttons = {
items.forEach { item ->
action(
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()
},
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(
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}"
},
@@ -1093,6 +1136,24 @@ fun MulticaCupertinoActionSheet(
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,
@@ -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
@OptIn(ExperimentalCupertinoApi::class)
+1 -1
View File
@@ -12,7 +12,7 @@
<main>
<h1>Multi-Casual Android</h1>
<p>Main site is published at <a href="https://multi-casual-android.pages.dev">https://multi-casual-android.pages.dev</a> and <a href="https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/">https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/</a>.</p>
<p>You can also install the signed APK directly from <a href="https://github.com/park0er/multi-casual-android/releases/download/v0.1.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>
<footer>
<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 = {
packageName: "ai.multicasual.app",
sha256: "317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4",
sha256: "3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49",
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",
multicaUrl: "https://multica.ai",
apiUrl: "https://api.multica.ai",
@@ -640,7 +640,7 @@ export default function App() {
<code>{release.sha256}</code>
</div>
</div>
<pre><code>{`adb install -r Multi-Casual-Android-v0.1.3.apk`}</code></pre>
<pre><code>{`adb install -r Multi-Casual-Android-v0.1.5.apk`}</code></pre>
<div className="cta-row compact">
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.download.latest}</a>
<a className="button secondary" href={release.githubUrl}>{t.download.source}</a>