Checkpoint public Android release state
This commit is contained in:
+28
-2
@@ -30,6 +30,17 @@ def umengPublicAppKey = configValue("multicaUmengPublicAppKey", "MULTICA_UMENG_P
|
||||
def postHogPublicApiKey = configValue("multicaPostHogPublicApiKey", "MULTICA_POSTHOG_PUBLIC_API_KEY", "")
|
||||
def postHogPublicHost = configValue("multicaPostHogPublicHost", "MULTICA_POSTHOG_PUBLIC_HOST", "https://us.i.posthog.com")
|
||||
def analyticsRoute = configValue("multicaAnalyticsRoute", "MULTICA_ANALYTICS_ROUTE", "auto")
|
||||
def releaseStoreFilePath = configValue("multicaReleaseStoreFile", "MULTICA_RELEASE_STORE_FILE", "")
|
||||
def releaseStorePassword = configValue("multicaReleaseStorePassword", "MULTICA_RELEASE_STORE_PASSWORD", "")
|
||||
def releaseKeyAlias = configValue("multicaReleaseKeyAlias", "MULTICA_RELEASE_KEY_ALIAS", "")
|
||||
def releaseKeyPassword = configValue("multicaReleaseKeyPassword", "MULTICA_RELEASE_KEY_PASSWORD", "")
|
||||
def hasReleaseSigning = !releaseStoreFilePath.isBlank()
|
||||
&& !releaseStorePassword.isBlank()
|
||||
&& !releaseKeyAlias.isBlank()
|
||||
&& !releaseKeyPassword.isBlank()
|
||||
def releaseStoreFile = releaseStoreFilePath.isBlank()
|
||||
? null
|
||||
: (new File(releaseStoreFilePath).isAbsolute() ? file(releaseStoreFilePath) : rootProject.file(releaseStoreFilePath))
|
||||
|
||||
android {
|
||||
namespace "ai.multica.app"
|
||||
@@ -40,8 +51,8 @@ android {
|
||||
applicationId "ai.multicasual.app"
|
||||
minSdk 24
|
||||
targetSdk 36
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionCode 2
|
||||
versionName "0.1.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -60,6 +71,18 @@ android {
|
||||
}
|
||||
|
||||
flavorDimensions "distribution"
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
if (hasReleaseSigning) {
|
||||
storeFile releaseStoreFile
|
||||
storePassword releaseStorePassword
|
||||
keyAlias releaseKeyAlias
|
||||
keyPassword releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
productFlavors {
|
||||
create("public") {
|
||||
dimension "distribution"
|
||||
@@ -87,6 +110,9 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,12 +237,12 @@ final class ApiClient {
|
||||
}
|
||||
|
||||
List<Models.Issue> searchIssues(String workspaceId, String text, int limit) throws Exception {
|
||||
JSONObject query = query(null,
|
||||
"workspace_id", workspaceId,
|
||||
"q", text,
|
||||
"limit", limit,
|
||||
"include_closed", "true");
|
||||
JSONObject json = requestObject("GET", "/api/issues/search", query, null);
|
||||
return searchIssues(workspaceId, null, text, limit);
|
||||
}
|
||||
|
||||
List<Models.Issue> searchIssues(String workspaceId, String workspaceSlug, String text, int limit) throws Exception {
|
||||
JSONObject json = requestObject("GET", "/api/issues/search",
|
||||
searchQuery(text, limit), null, searchWorkspaceHeaders(workspaceId, workspaceSlug));
|
||||
return parseArray(extractArray(json, "issues"), Models.Issue::new);
|
||||
}
|
||||
|
||||
@@ -582,12 +582,12 @@ final class ApiClient {
|
||||
}
|
||||
|
||||
List<Models.Project> searchProjects(String workspaceId, String text, int limit) throws Exception {
|
||||
JSONObject query = query(null,
|
||||
"workspace_id", workspaceId,
|
||||
"q", text,
|
||||
"limit", limit,
|
||||
"include_closed", "true");
|
||||
JSONObject json = requestObject("GET", "/api/projects/search", query, null);
|
||||
return searchProjects(workspaceId, null, text, limit);
|
||||
}
|
||||
|
||||
List<Models.Project> searchProjects(String workspaceId, String workspaceSlug, String text, int limit) throws Exception {
|
||||
JSONObject json = requestObject("GET", "/api/projects/search",
|
||||
searchQuery(text, limit), null, searchWorkspaceHeaders(workspaceId, workspaceSlug));
|
||||
return parseArray(extractArray(json, "projects"), Models.Project::new);
|
||||
}
|
||||
|
||||
@@ -1531,6 +1531,25 @@ final class ApiClient {
|
||||
return obj;
|
||||
}
|
||||
|
||||
static JSONObject searchQuery(String text, int limit) throws Exception {
|
||||
return query(null,
|
||||
"q", text,
|
||||
"limit", limit,
|
||||
"include_closed", "true");
|
||||
}
|
||||
|
||||
static Map<String, String> searchWorkspaceHeaders(String workspaceId, String workspaceSlug) {
|
||||
String slug = workspaceSlug == null ? "" : workspaceSlug.trim();
|
||||
if (!slug.isEmpty()) {
|
||||
return Collections.singletonMap("X-Workspace-Slug", slug);
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -23,7 +23,7 @@ public final class AppAnalytics {
|
||||
private AppAnalytics() {}
|
||||
|
||||
public static boolean shouldPromptForConsent(Context context) {
|
||||
return !new AnalyticsConsentStore(context).hasDecision();
|
||||
return !new AnalyticsConsentStore(context).isGranted();
|
||||
}
|
||||
|
||||
public static void grantConsentAndInitialize(Context context) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package ai.multica.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color as AndroidColor
|
||||
@@ -554,6 +556,19 @@ private fun ComposePilotApp(
|
||||
var showAnalyticsConsent by remember { mutableStateOf(AppAnalytics.shouldPromptForConsent(context)) }
|
||||
val zh = authStore.isChinese()
|
||||
|
||||
fun exitApp() {
|
||||
var currentContext: Context? = context
|
||||
while (currentContext is ContextWrapper) {
|
||||
if (currentContext is Activity) {
|
||||
currentContext.finishAffinity()
|
||||
return
|
||||
}
|
||||
currentContext = currentContext.baseContext
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = showAnalyticsConsent) { exitApp() }
|
||||
|
||||
LaunchedEffect(refresh) {
|
||||
appState = PilotState.Loading
|
||||
if (authStore.token().isNullOrBlank()) {
|
||||
@@ -620,17 +635,14 @@ private fun ComposePilotApp(
|
||||
visible = showAnalyticsConsent,
|
||||
title = if (zh) "允许基础使用统计?" else "Allow basic usage analytics?",
|
||||
message = if (zh) {
|
||||
"Multi-Casual 只会上报 app_open 启动事件,用于统计用户量和日活。中国大陆用户使用友盟SDK,海外用户使用 PostHog SDK;SDK 可能按各自规则处理设备信息、Android ID、IP 地址和网络状态。我们不会采集页面浏览、聊天内容、文件内容、邮箱、Token 或精确位置。你可以选择不同意,应用仍可继续使用。"
|
||||
"Multi-Casual 只会上报 app_open 启动事件,用于统计用户量和日活。中国大陆用户使用友盟SDK,海外用户使用 PostHog SDK;SDK 可能按各自规则处理设备信息、Android ID、IP 地址和网络状态。我们不会采集页面浏览、聊天内容、文件内容、邮箱、Token 或精确位置。不同意将退出应用。"
|
||||
} else {
|
||||
"Multi-Casual only reports the app_open event to measure user count and daily active users. Mainland China uses the Umeng SDK, while global users use the PostHog SDK; each SDK may process device information, Android ID, IP address, and network state under its own policy. We do not collect page views, chat content, file content, email, tokens, or precise location. You can decline and still use the app."
|
||||
"Multi-Casual only reports the app_open event to measure user count and daily active users. Mainland China uses the Umeng SDK, while global users use the PostHog SDK; each SDK may process device information, Android ID, IP address, and network state under its own policy. We do not collect page views, chat content, file content, email, tokens, or precise location. Declining exits the app."
|
||||
},
|
||||
cancelText = if (zh) "不同意" else "Decline",
|
||||
confirmText = if (zh) "同意并继续" else "Agree",
|
||||
cancelText = if (zh) "不同意并退出" else "Decline and Exit",
|
||||
confirmText = if (zh) "同意并继续使用" else "Agree and Continue",
|
||||
contentDescription = "Analytics Consent",
|
||||
onDismissRequest = {
|
||||
AppAnalytics.denyConsent(context)
|
||||
showAnalyticsConsent = false
|
||||
},
|
||||
onDismissRequest = { exitApp() },
|
||||
onConfirm = {
|
||||
AppAnalytics.grantConsentAndInitialize(context)
|
||||
showAnalyticsConsent = false
|
||||
@@ -740,7 +752,7 @@ private fun PilotLoginScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = if (zh) "输入邮箱获取验证码,和 Web/桌面端使用同一套官方登录服务。" else "Use your email code. This is the same official sign-in service as web and desktop.",
|
||||
text = if (zh) "输入邮箱获取验证码,和 Web/桌面端使用同一套邮箱验证码登录服务。" else "Use your email code. This is the same email-code sign-in service as web and desktop.",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, lineHeight = 21.sp),
|
||||
color = MulticaColors.Muted,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -845,16 +857,6 @@ private fun PilotLoginScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
MulticaPillButton(
|
||||
text = if (zh) "用浏览器打开 Web 登录" else "Open Web Sign In",
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("${BuildConfig.MULTICA_WEB_BASE_URL}/login")))
|
||||
},
|
||||
tone = MulticaButtonTone.Ghost,
|
||||
contentDescription = "Login Open Web Sign In",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1755,6 +1757,7 @@ private fun ComposePilotShell(
|
||||
PilotSearchPage(
|
||||
api = api,
|
||||
workspaceId = session.workspace.id,
|
||||
workspaceSlug = session.workspace.slug,
|
||||
zh = zh,
|
||||
query = retainedSearchQuery,
|
||||
onQueryChange = { retainedSearchQuery = it },
|
||||
@@ -2003,6 +2006,7 @@ private fun PilotTabContent(
|
||||
private fun PilotSearchPage(
|
||||
api: ApiClient,
|
||||
workspaceId: String,
|
||||
workspaceSlug: String,
|
||||
zh: Boolean,
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
@@ -2031,8 +2035,8 @@ private fun PilotSearchPage(
|
||||
val searchResult = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
PilotSearchData(
|
||||
issues = api.searchIssues(workspaceId, text, 20),
|
||||
projects = api.searchProjects(workspaceId, text, 10),
|
||||
issues = api.searchIssues(workspaceId, workspaceSlug, text, 20),
|
||||
projects = api.searchProjects(workspaceId, workspaceSlug, text, 10),
|
||||
agents = runCatching { searchLocalAgents(api.agents(workspaceId), text) }.getOrElse { emptyList() },
|
||||
skills = runCatching { searchLocalSkills(api.skills(workspaceId), text) }.getOrElse { emptyList() },
|
||||
)
|
||||
@@ -9481,7 +9485,7 @@ private fun PilotIssueDetail(
|
||||
issueReferenceMessage = null
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { api.searchIssues(workspaceId, identifier, 10) }
|
||||
runCatching { api.searchIssues(workspaceId, workspaceSlug, identifier, 10) }
|
||||
}
|
||||
result.onSuccess { issues ->
|
||||
val match = issues.firstOrNull { it.identifier.equals(identifier, ignoreCase = true) }
|
||||
@@ -9799,7 +9803,7 @@ private fun PilotIssueDetail(
|
||||
}
|
||||
|
||||
fun sendNewComment() {
|
||||
val content = newCommentDraft.trim()
|
||||
val content = AgentMentionMarkdown.markdownFromDraft(newCommentDraft.trim(), data.agents)
|
||||
if (sendingNewComment || content.isEmpty() && pendingCommentAttachments.isEmpty()) return
|
||||
sendingNewComment = true
|
||||
newCommentMessage = null
|
||||
@@ -9820,8 +9824,10 @@ private fun PilotIssueDetail(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReply(parentId: String) {
|
||||
val content = replyDraft.trim()
|
||||
fun sendReply(thread: IssueCommentThreads.Thread) {
|
||||
val parentId = thread.root.id
|
||||
val explicitContent = AgentMentionMarkdown.markdownFromDraft(replyDraft.trim(), data.agents)
|
||||
val content = IssueCommentThreads.replyContentForThread(thread, explicitContent, data.agents)
|
||||
if (sendingReply || (content.isEmpty() && pendingReplyAttachments.isEmpty())) return
|
||||
sendingReply = true
|
||||
replyMessage = null
|
||||
@@ -9872,6 +9878,10 @@ private fun PilotIssueDetail(
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: data.issue.projectId?.takeIf { it.isNotBlank() }?.let { Models.shortId(it) }
|
||||
?: if (zh) "无项目" else "No project"
|
||||
val commentThreads = IssueCommentThreads.build(data.comments, commentsDescending)
|
||||
val replyingThread = replyingCommentId?.let { activeReplyId ->
|
||||
commentThreads.firstOrNull { it.root.id == activeReplyId }
|
||||
}
|
||||
|
||||
PilotListPage(
|
||||
title = "${data.issue.identifier} · ${Models.statusLabel(data.issue.status, zh)}",
|
||||
@@ -9915,18 +9925,44 @@ private fun PilotIssueDetail(
|
||||
contentDescription = "Issue Comment Bottom Haze Composer $WEB_SSOT_COMMENT_INPUT"
|
||||
},
|
||||
) {
|
||||
IssueCommentInputBar(
|
||||
draft = newCommentDraft,
|
||||
pendingAttachments = pendingCommentAttachments,
|
||||
agents = data.agents,
|
||||
uploadingAttachment = uploadingCommentAttachment,
|
||||
sending = sendingNewComment,
|
||||
zh = zh,
|
||||
onDraftChange = { newCommentDraft = it },
|
||||
onAttach = { if (!uploadingCommentAttachment) commentAttachmentLauncher.launch("*/*") },
|
||||
onAttachImage = { if (!uploadingCommentAttachment) commentImageAttachmentLauncher.launch("image/*") },
|
||||
onSend = { sendNewComment() },
|
||||
)
|
||||
if (replyingThread != null) {
|
||||
IssueCommentReplyBox(
|
||||
parentId = replyingThread.root.id,
|
||||
draft = replyDraft,
|
||||
pendingAttachments = pendingReplyAttachments,
|
||||
agents = data.agents,
|
||||
uploadingAttachment = uploadingReplyAttachment,
|
||||
sending = sendingReply,
|
||||
zh = zh,
|
||||
bottomMode = true,
|
||||
onDraftChange = { replyDraft = it },
|
||||
onAttach = { if (!uploadingReplyAttachment) replyAttachmentLauncher.launch("*/*") },
|
||||
onAttachImage = { if (!uploadingReplyAttachment) replyImageAttachmentLauncher.launch("image/*") },
|
||||
onRemoveAttachment = { removeIndex ->
|
||||
pendingReplyAttachments = pendingReplyAttachments.filterIndexed { index, _ -> index != removeIndex }
|
||||
},
|
||||
onCancel = {
|
||||
replyingCommentId = null
|
||||
replyDraft = ""
|
||||
pendingReplyAttachments = emptyList()
|
||||
replyMessage = null
|
||||
},
|
||||
onSend = { sendReply(replyingThread) },
|
||||
)
|
||||
} else {
|
||||
IssueCommentInputBar(
|
||||
draft = newCommentDraft,
|
||||
pendingAttachments = pendingCommentAttachments,
|
||||
agents = data.agents,
|
||||
uploadingAttachment = uploadingCommentAttachment,
|
||||
sending = sendingNewComment,
|
||||
zh = zh,
|
||||
onDraftChange = { newCommentDraft = it },
|
||||
onAttach = { if (!uploadingCommentAttachment) commentAttachmentLauncher.launch("*/*") },
|
||||
onAttachImage = { if (!uploadingCommentAttachment) commentImageAttachmentLauncher.launch("image/*") },
|
||||
onSend = { sendNewComment() },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
@@ -10110,166 +10146,158 @@ private fun PilotIssueDetail(
|
||||
)
|
||||
}
|
||||
}
|
||||
val comments = if (commentsDescending) {
|
||||
data.comments.sortedByDescending { it.createdAt }
|
||||
} else {
|
||||
data.comments.sortedBy { it.createdAt }
|
||||
}
|
||||
if (comments.isEmpty()) {
|
||||
val threads = commentThreads
|
||||
if (threads.isEmpty()) {
|
||||
item { IssueDetailCommentsWebEmptyState(zh = zh) }
|
||||
}
|
||||
val baseVisibleCount = if (showExpandedCommentHistory) 8 else 2
|
||||
val highlightedIndex = highlightCommentId?.let { targetId ->
|
||||
comments.indexOfFirst { it.id == targetId || it.parentId == targetId }.takeIf { it >= 0 }
|
||||
threads.indexOfFirst { thread ->
|
||||
thread.root.id == targetId || thread.replies.any { it.id == targetId }
|
||||
}.takeIf { it >= 0 }
|
||||
}
|
||||
val visibleCommentCount = maxOf(
|
||||
val visibleThreadCount = maxOf(
|
||||
baseVisibleCount,
|
||||
highlightedIndex?.plus(1) ?: 0,
|
||||
).coerceAtMost(comments.size)
|
||||
val visibleComments = comments.take(visibleCommentCount)
|
||||
items(visibleComments) { comment ->
|
||||
val content = clean(comment.content)
|
||||
val authorName = Models.commentAuthorDisplayName(comment, data.members, data.agents, currentUser)
|
||||
val authorAvatarUrl = Models.commentAuthorAvatarUrl(comment, data.members, data.agents, currentUser)
|
||||
IssueCommentCard(
|
||||
comment = comment,
|
||||
authorName = authorName,
|
||||
authorAvatarUrl = authorAvatarUrl,
|
||||
authorAgentStatus = commentAuthorAgentStatus(comment, data.agents),
|
||||
members = data.members,
|
||||
agents = data.agents,
|
||||
currentUser = currentUser,
|
||||
onIssueIdClick = { onOpenIssue(it) },
|
||||
onIssueReferenceClick = { openIssueReference(it) },
|
||||
expandedActions = expandedCommentActionsId == comment.id,
|
||||
contentExpanded = comment.id in expandedCommentContentIds,
|
||||
highlighted = comment.id == highlightCommentId,
|
||||
zh = zh,
|
||||
onToggleActions = {
|
||||
expandedCommentActionsId = if (expandedCommentActionsId == comment.id) null else comment.id
|
||||
},
|
||||
onToggleContent = {
|
||||
expandedCommentContentIds = if (comment.id in expandedCommentContentIds) {
|
||||
expandedCommentContentIds - comment.id
|
||||
} else {
|
||||
expandedCommentContentIds + comment.id
|
||||
).coerceAtMost(threads.size)
|
||||
val visibleThreads = threads.take(visibleThreadCount)
|
||||
items(visibleThreads) { thread ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics(mergeDescendants = false) {
|
||||
contentDescription = "Issue Comment Thread ${thread.root.id}"
|
||||
},
|
||||
) {
|
||||
(listOf(thread.root) + thread.replies).forEach { comment ->
|
||||
val authorName = Models.commentAuthorDisplayName(comment, data.members, data.agents, currentUser)
|
||||
val authorAvatarUrl = Models.commentAuthorAvatarUrl(comment, data.members, data.agents, currentUser)
|
||||
val isThreadReply = comment.id != thread.root.id
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = if (isThreadReply) 28.dp else 0.dp),
|
||||
) {
|
||||
IssueCommentCard(
|
||||
comment = comment,
|
||||
authorName = authorName,
|
||||
authorAvatarUrl = authorAvatarUrl,
|
||||
authorAgentStatus = commentAuthorAgentStatus(comment, data.agents),
|
||||
members = data.members,
|
||||
agents = data.agents,
|
||||
currentUser = currentUser,
|
||||
onIssueIdClick = { onOpenIssue(it) },
|
||||
onIssueReferenceClick = { openIssueReference(it) },
|
||||
expandedActions = expandedCommentActionsId == comment.id,
|
||||
contentExpanded = comment.id in expandedCommentContentIds,
|
||||
highlighted = comment.id == highlightCommentId,
|
||||
zh = zh,
|
||||
onToggleActions = {
|
||||
expandedCommentActionsId = if (expandedCommentActionsId == comment.id) null else comment.id
|
||||
},
|
||||
onToggleContent = {
|
||||
expandedCommentContentIds = if (comment.id in expandedCommentContentIds) {
|
||||
expandedCommentContentIds - comment.id
|
||||
} else {
|
||||
expandedCommentContentIds + comment.id
|
||||
}
|
||||
},
|
||||
onReplyAction = {
|
||||
replyingCommentId = if (replyingCommentId == thread.root.id) null else thread.root.id
|
||||
replyDraft = ""
|
||||
pendingReplyAttachments = emptyList()
|
||||
editingCommentId = null
|
||||
editingCommentContent = ""
|
||||
pendingDeleteComment = null
|
||||
replyMessage = null
|
||||
},
|
||||
onEditAction = {
|
||||
editingCommentId = comment.id
|
||||
editingCommentContent = comment.content
|
||||
pendingDeleteComment = null
|
||||
replyingCommentId = null
|
||||
replyDraft = ""
|
||||
},
|
||||
onDeleteAction = {
|
||||
pendingDeleteComment = comment
|
||||
editingCommentId = null
|
||||
editingCommentContent = ""
|
||||
replyingCommentId = null
|
||||
replyDraft = ""
|
||||
},
|
||||
)
|
||||
if (comment.attachments.isNotEmpty()) {
|
||||
CompactInfoPanel(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 38.dp, bottom = 8.dp),
|
||||
) {
|
||||
comment.attachments.forEachIndexed { index, attachment ->
|
||||
CommentAttachmentWebDenseRow(
|
||||
attachment = attachment,
|
||||
zh = zh,
|
||||
showDivider = index < comment.attachments.lastIndex,
|
||||
onOpen = { openAttachment(attachment) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (editingCommentId == comment.id) {
|
||||
MulticaTextField(
|
||||
value = editingCommentContent,
|
||||
onValueChange = { editingCommentContent = it },
|
||||
label = if (zh) "编辑评论" else "Edit comment",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics { contentDescription = "Issue Comment Edit Field ${comment.id}" },
|
||||
minLines = 3,
|
||||
maxLines = 8,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
MulticaPillButton(
|
||||
text = if (zh) "取消" else "Cancel",
|
||||
onClick = {
|
||||
editingCommentId = null
|
||||
editingCommentContent = ""
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
tone = MulticaButtonTone.Ghost,
|
||||
)
|
||||
MulticaPillButton(
|
||||
text = if (zh) "保存评论" else "Save Comment",
|
||||
onClick = {
|
||||
val nextContent = editingCommentContent.trim()
|
||||
if (nextContent.isEmpty()) return@MulticaPillButton
|
||||
runCommentMutation { api.updateComment(workspaceId, comment.id, nextContent) }
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
tone = MulticaButtonTone.Primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (pendingDeleteComment?.id == comment.id) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 38.dp, bottom = 8.dp)
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = "Issue Comment Delete Cupertino Alert Preview Row ${comment.id}"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onReplyAction = {
|
||||
replyingCommentId = if (replyingCommentId == comment.id) null else comment.id
|
||||
replyDraft = ""
|
||||
pendingReplyAttachments = emptyList()
|
||||
editingCommentId = null
|
||||
editingCommentContent = ""
|
||||
pendingDeleteComment = null
|
||||
replyMessage = null
|
||||
},
|
||||
onEditAction = {
|
||||
editingCommentId = comment.id
|
||||
editingCommentContent = comment.content
|
||||
pendingDeleteComment = null
|
||||
replyingCommentId = null
|
||||
replyDraft = ""
|
||||
},
|
||||
onDeleteAction = {
|
||||
pendingDeleteComment = comment
|
||||
editingCommentId = null
|
||||
editingCommentContent = ""
|
||||
replyingCommentId = null
|
||||
replyDraft = ""
|
||||
},
|
||||
)
|
||||
if (replyingCommentId == comment.id) {
|
||||
IssueCommentReplyBox(
|
||||
parentId = comment.id,
|
||||
draft = replyDraft,
|
||||
pendingAttachments = pendingReplyAttachments,
|
||||
agents = data.agents,
|
||||
uploadingAttachment = uploadingReplyAttachment,
|
||||
sending = sendingReply,
|
||||
zh = zh,
|
||||
onDraftChange = { replyDraft = it },
|
||||
onAttach = { if (!uploadingReplyAttachment) replyAttachmentLauncher.launch("*/*") },
|
||||
onAttachImage = { if (!uploadingReplyAttachment) replyImageAttachmentLauncher.launch("image/*") },
|
||||
onRemoveAttachment = { removeIndex ->
|
||||
pendingReplyAttachments = pendingReplyAttachments.filterIndexed { index, _ -> index != removeIndex }
|
||||
},
|
||||
onCancel = {
|
||||
replyingCommentId = null
|
||||
replyDraft = ""
|
||||
pendingReplyAttachments = emptyList()
|
||||
replyMessage = null
|
||||
},
|
||||
onSend = { sendReply(comment.id) },
|
||||
)
|
||||
}
|
||||
if (!replyMessage.isNullOrBlank() && replyingCommentId == comment.id) {
|
||||
}
|
||||
if (!replyMessage.isNullOrBlank() && replyingCommentId == thread.root.id) {
|
||||
PilotInlineResultState(
|
||||
message = replyMessage.orEmpty(),
|
||||
isError = pilotInlineResultIsError(replyMessage.orEmpty()),
|
||||
contentDescription = "Issue Detail Reply Result Web Banner",
|
||||
)
|
||||
}
|
||||
if (comment.attachments.isNotEmpty()) {
|
||||
CompactInfoPanel(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 38.dp, bottom = 8.dp),
|
||||
) {
|
||||
comment.attachments.forEachIndexed { index, attachment ->
|
||||
CommentAttachmentWebDenseRow(
|
||||
attachment = attachment,
|
||||
zh = zh,
|
||||
showDivider = index < comment.attachments.lastIndex,
|
||||
onOpen = { openAttachment(attachment) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (editingCommentId == comment.id) {
|
||||
MulticaTextField(
|
||||
value = editingCommentContent,
|
||||
onValueChange = { editingCommentContent = it },
|
||||
label = if (zh) "编辑评论" else "Edit comment",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics { contentDescription = "Issue Comment Edit Field ${comment.id}" },
|
||||
minLines = 3,
|
||||
maxLines = 8,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
MulticaPillButton(
|
||||
text = if (zh) "取消" else "Cancel",
|
||||
onClick = {
|
||||
editingCommentId = null
|
||||
editingCommentContent = ""
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
tone = MulticaButtonTone.Ghost,
|
||||
)
|
||||
MulticaPillButton(
|
||||
text = if (zh) "保存评论" else "Save Comment",
|
||||
onClick = {
|
||||
val nextContent = editingCommentContent.trim()
|
||||
if (nextContent.isEmpty()) return@MulticaPillButton
|
||||
runCommentMutation { api.updateComment(workspaceId, comment.id, nextContent) }
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
tone = MulticaButtonTone.Primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (pendingDeleteComment?.id == comment.id) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 38.dp, bottom = 8.dp)
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = "Issue Comment Delete Cupertino Alert Preview Row ${comment.id}"
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!commentMessage.isNullOrBlank()) {
|
||||
}
|
||||
if (!commentMessage.isNullOrBlank()) {
|
||||
item {
|
||||
PilotInlineResultState(
|
||||
message = commentMessage.orEmpty(),
|
||||
isError = pilotInlineResultIsError(commentMessage.orEmpty()),
|
||||
@@ -10277,34 +10305,34 @@ private fun PilotIssueDetail(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (comments.size > visibleComments.size) {
|
||||
if (threads.size > visibleThreads.size) {
|
||||
item {
|
||||
IssueDetailWebActionRow(
|
||||
eyebrow = if (zh) "评论历史" else "Comment history",
|
||||
title = if (zh) {
|
||||
if (showExpandedCommentHistory) {
|
||||
"已显示最新 ${visibleComments.size} 条,点此收起"
|
||||
"已显示最新 ${visibleThreads.size} 个帖子,点此收起"
|
||||
} else {
|
||||
"已优先显示最新 ${visibleComments.size} 条,点此查看更多"
|
||||
"已优先显示最新 ${visibleThreads.size} 个帖子,点此查看更多"
|
||||
}
|
||||
} else {
|
||||
if (showExpandedCommentHistory) {
|
||||
"Showing latest ${visibleComments.size}; tap to collapse"
|
||||
"Showing latest ${visibleThreads.size} threads; tap to collapse"
|
||||
} else {
|
||||
"Showing latest ${visibleComments.size}; tap for more"
|
||||
"Showing latest ${visibleThreads.size} threads; tap for more"
|
||||
}
|
||||
},
|
||||
subtitle = if (zh) {
|
||||
if (showExpandedCommentHistory) {
|
||||
"还有 ${comments.size - visibleComments.size} 条更早评论"
|
||||
"还有 ${threads.size - visibleThreads.size} 个更早帖子"
|
||||
} else {
|
||||
"还有 ${comments.size - visibleComments.size} 条历史评论"
|
||||
"还有 ${threads.size - visibleThreads.size} 个历史帖子"
|
||||
}
|
||||
} else {
|
||||
if (showExpandedCommentHistory) {
|
||||
"${comments.size - visibleComments.size} older comments"
|
||||
"${threads.size - visibleThreads.size} older threads"
|
||||
} else {
|
||||
"${comments.size - visibleComments.size} older comments"
|
||||
"${threads.size - visibleThreads.size} older threads"
|
||||
}
|
||||
},
|
||||
icon = if (showExpandedCommentHistory) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown,
|
||||
@@ -10716,6 +10744,7 @@ private fun IssueCommentReplyBox(
|
||||
uploadingAttachment: Boolean,
|
||||
sending: Boolean,
|
||||
zh: Boolean,
|
||||
bottomMode: Boolean = false,
|
||||
onDraftChange: (String) -> Unit,
|
||||
onAttach: () -> Unit,
|
||||
onAttachImage: () -> Unit,
|
||||
@@ -10728,6 +10757,7 @@ private fun IssueCommentReplyBox(
|
||||
val focusRequester = remember(parentId) { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val replyActionModifier = Modifier.size(42.dp)
|
||||
val mentionAgents = remember(agents, mentionQuery) {
|
||||
val query = mentionQuery.trim().lowercase()
|
||||
agents
|
||||
@@ -10750,7 +10780,7 @@ private fun IssueCommentReplyBox(
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 38.dp, bottom = 8.dp)
|
||||
.padding(start = if (bottomMode) 0.dp else 38.dp, bottom = if (bottomMode) 0.dp else 8.dp)
|
||||
.semantics(mergeDescendants = false) { contentDescription = "Issue Comment Web Reply Editor $parentId" },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MulticaColors.Surface.copy(alpha = 0.82f),
|
||||
@@ -10796,25 +10826,28 @@ private fun IssueCommentReplyBox(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MulticaPillButton(
|
||||
text = if (uploadingAttachment) "..." else if (zh) "图片" else "Image",
|
||||
MulticaIconPillButton(
|
||||
icon = Icons.Outlined.Image,
|
||||
contentDescription = "Issue Comment Reply Attach Image",
|
||||
onClick = onAttachImage,
|
||||
tone = MulticaButtonTone.Secondary,
|
||||
contentDescription = "Issue Comment Reply Attach Image",
|
||||
enabled = !uploadingAttachment,
|
||||
modifier = replyActionModifier,
|
||||
)
|
||||
MulticaPillButton(
|
||||
text = "+",
|
||||
onClick = onAttach,
|
||||
tone = MulticaButtonTone.Ghost,
|
||||
MulticaIconPillButton(
|
||||
icon = Icons.Outlined.AttachFile,
|
||||
contentDescription = "Issue Comment Reply Attach",
|
||||
onClick = onAttach,
|
||||
tone = MulticaButtonTone.Secondary,
|
||||
enabled = !uploadingAttachment,
|
||||
modifier = replyActionModifier,
|
||||
)
|
||||
MulticaPillButton(
|
||||
text = "@",
|
||||
MulticaIconPillButton(
|
||||
icon = Icons.Outlined.AlternateEmail,
|
||||
contentDescription = "Issue Comment Reply Agent Mention",
|
||||
onClick = { mentionPickerOpen = !mentionPickerOpen },
|
||||
tone = if (mentionPickerOpen) MulticaButtonTone.Primary else MulticaButtonTone.Secondary,
|
||||
contentDescription = "Issue Comment Reply Agent Mention",
|
||||
modifier = replyActionModifier,
|
||||
)
|
||||
MulticaPillButton(
|
||||
text = if (zh) "取消" else "Cancel",
|
||||
@@ -10831,7 +10864,7 @@ private fun IssueCommentReplyBox(
|
||||
onClick = onSend,
|
||||
tone = MulticaButtonTone.Primary,
|
||||
enabled = !sending && (draft.trim().isNotEmpty() || pendingAttachments.isNotEmpty()),
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1.18f).widthIn(min = 88.dp),
|
||||
)
|
||||
}
|
||||
if (mentionPickerOpen) {
|
||||
@@ -10867,7 +10900,7 @@ private fun IssueCommentReplyBox(
|
||||
agent = agent,
|
||||
contentDescription = "Issue Comment Reply Mention Agent Row ${agent.id}",
|
||||
onClick = {
|
||||
val insertion = AgentMentionMarkdown.markdown(agent.name, agent.id)
|
||||
val insertion = AgentMentionMarkdown.displayText(agent.name, agent.id)
|
||||
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
|
||||
onDraftChange(draft + separator + insertion + " ")
|
||||
mentionPickerOpen = false
|
||||
@@ -10935,7 +10968,7 @@ private fun IssueMentionAgentWebRow(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = AgentMentionMarkdown.markdown(agent.name, agent.id),
|
||||
text = AgentMentionMarkdown.displayText(agent.name, agent.id),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, fontFamily = FontFamily.Monospace),
|
||||
color = MulticaColors.Muted,
|
||||
maxLines = 1,
|
||||
@@ -11110,7 +11143,7 @@ private fun IssueCommentInputBar(
|
||||
agent = agent,
|
||||
contentDescription = "Issue Comment Mention Agent Row ${agent.id}",
|
||||
onClick = {
|
||||
val insertion = AgentMentionMarkdown.markdown(agent.name, agent.id)
|
||||
val insertion = AgentMentionMarkdown.displayText(agent.name, agent.id)
|
||||
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
|
||||
onDraftChange(draft + separator + insertion + " ")
|
||||
mentionPickerOpen = false
|
||||
@@ -26866,10 +26899,33 @@ private fun agentTasksSummary(agent: Models.Agent, data: PilotAgentSettingsData,
|
||||
|
||||
private fun shortDate(value: String?): String {
|
||||
val text = clean(value)
|
||||
val timestampMillis = parseTimestampMillis(text)
|
||||
if (timestampMillis != null) {
|
||||
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
|
||||
formatter.timeZone = TimeZone.getDefault()
|
||||
return formatter.format(Date(timestampMillis))
|
||||
}
|
||||
if (text.length >= 16) return text.substring(0, 16).replace('T', ' ')
|
||||
return text
|
||||
}
|
||||
|
||||
private fun parseTimestampMillis(value: String?): Long? {
|
||||
val text = clean(value)
|
||||
if (text.isBlank() || !text.contains('T')) return null
|
||||
val patterns = listOf(
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
"yyyy-MM-dd'T'HH:mm:ssXXX",
|
||||
)
|
||||
for (pattern in patterns) {
|
||||
val formatter = SimpleDateFormat(pattern, Locale.US)
|
||||
if (pattern.endsWith("'Z'")) formatter.timeZone = TimeZone.getTimeZone("UTC")
|
||||
val parsed = runCatching { formatter.parse(text)?.time }.getOrNull()
|
||||
if (parsed != null) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun relativeDate(value: String?, zh: Boolean): String {
|
||||
val millis = parseDueDateMillis(value) ?: return shortDate(value)
|
||||
val deltaSeconds = ((System.currentTimeMillis() - millis) / 1000).coerceAtLeast(0)
|
||||
|
||||
@@ -20,6 +20,37 @@ final class AgentMentionMarkdown {
|
||||
return "[@" + cleanName + "](mention://agent/" + agentId + ")";
|
||||
}
|
||||
|
||||
static String displayText(String name, String agentId) {
|
||||
String cleanName = cleanName(name);
|
||||
if (cleanName.isEmpty()) cleanName = Models.shortId(agentId);
|
||||
return "@" + cleanName;
|
||||
}
|
||||
|
||||
static String markdownFromDraft(String draft, List<Models.Agent> agents) {
|
||||
String content = draft == null ? "" : draft.trim();
|
||||
if (content.isEmpty() || content.contains("mention://agent/") || agents == null || agents.isEmpty()) {
|
||||
return content;
|
||||
}
|
||||
ArrayList<Models.Agent> sortedAgents = new ArrayList<>(agents);
|
||||
sortedAgents.sort((left, right) -> Integer.compare(
|
||||
displayText(right.name, right.id).length(),
|
||||
displayText(left.name, left.id).length()
|
||||
));
|
||||
String normalized = content;
|
||||
for (Models.Agent agent : sortedAgents) {
|
||||
String display = displayText(agent.name, agent.id);
|
||||
if (display.length() <= 1) continue;
|
||||
normalized = normalized.replace(display, markdown(agent.name, agent.id));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static String cleanName(String name) {
|
||||
if (name == null) return "";
|
||||
String trimmed = name.trim();
|
||||
return "null".equalsIgnoreCase(trimmed) ? "" : trimmed;
|
||||
}
|
||||
|
||||
private AgentMentionMarkdown() {
|
||||
}
|
||||
}
|
||||
@@ -132,6 +163,127 @@ final class IssueCommentRichText {
|
||||
}
|
||||
}
|
||||
|
||||
final class IssueCommentThreads {
|
||||
static final class Thread {
|
||||
final Models.Comment root;
|
||||
final List<Models.Comment> replies;
|
||||
|
||||
Thread(Models.Comment root, List<Models.Comment> replies) {
|
||||
this.root = root;
|
||||
this.replies = Collections.unmodifiableList(replies);
|
||||
}
|
||||
}
|
||||
|
||||
static List<Thread> build(List<Models.Comment> comments, boolean descending) {
|
||||
LinkedHashMap<String, Models.Comment> byId = new LinkedHashMap<>();
|
||||
for (Models.Comment comment : comments) {
|
||||
if (comment != null && !clean(comment.id).isEmpty()) byId.put(comment.id, comment);
|
||||
}
|
||||
|
||||
LinkedHashMap<String, ArrayList<Models.Comment>> repliesByRoot = new LinkedHashMap<>();
|
||||
ArrayList<Models.Comment> roots = new ArrayList<>();
|
||||
for (Models.Comment comment : comments) {
|
||||
if (comment == null) continue;
|
||||
Models.Comment root = rootFor(comment, byId);
|
||||
if (root == comment || !byId.containsKey(clean(root.id))) {
|
||||
if (!roots.contains(comment)) roots.add(comment);
|
||||
repliesByRoot.putIfAbsent(clean(comment.id), new ArrayList<>());
|
||||
} else {
|
||||
repliesByRoot.putIfAbsent(clean(root.id), new ArrayList<>());
|
||||
repliesByRoot.get(clean(root.id)).add(comment);
|
||||
}
|
||||
}
|
||||
|
||||
roots.sort((left, right) -> descending
|
||||
? compareCreatedAt(right, left)
|
||||
: compareCreatedAt(left, right));
|
||||
ArrayList<Thread> threads = new ArrayList<>();
|
||||
for (Models.Comment root : roots) {
|
||||
ArrayList<Models.Comment> replies = repliesByRoot.getOrDefault(clean(root.id), new ArrayList<>());
|
||||
replies.sort(IssueCommentThreads::compareCreatedAt);
|
||||
threads.add(new Thread(root, replies));
|
||||
}
|
||||
return threads;
|
||||
}
|
||||
|
||||
static List<String> replyIds(Thread thread) {
|
||||
ArrayList<String> ids = new ArrayList<>();
|
||||
if (thread == null) return ids;
|
||||
for (Models.Comment reply : thread.replies) ids.add(reply.id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
static String replyContentForThread(Thread thread, String draft, List<Models.Agent> agents) {
|
||||
String content = clean(draft);
|
||||
if (content.contains("mention://agent/")) return content;
|
||||
String agentId = firstAgentParticipantId(thread);
|
||||
if (agentId.isEmpty()) return content;
|
||||
String mention = AgentMentionMarkdown.markdown(agentName(agentId, agents), agentId);
|
||||
return content.isEmpty() ? mention : mention + "\n\n" + content;
|
||||
}
|
||||
|
||||
private static Models.Comment rootFor(Models.Comment comment, Map<String, Models.Comment> byId) {
|
||||
Models.Comment current = comment;
|
||||
ArrayList<String> seen = new ArrayList<>();
|
||||
while (current != null && !isRoot(current)) {
|
||||
String parentId = clean(current.parentId);
|
||||
if (seen.contains(parentId)) return comment;
|
||||
seen.add(parentId);
|
||||
Models.Comment parent = byId.get(parentId);
|
||||
if (parent == null) return comment;
|
||||
current = parent;
|
||||
}
|
||||
return current == null ? comment : current;
|
||||
}
|
||||
|
||||
private static String firstAgentParticipantId(Thread thread) {
|
||||
if (thread == null) return "";
|
||||
String rootAgentId = agentParticipantId(thread.root);
|
||||
if (!rootAgentId.isEmpty()) return rootAgentId;
|
||||
for (Models.Comment reply : thread.replies) {
|
||||
String agentId = agentParticipantId(reply);
|
||||
if (!agentId.isEmpty()) return agentId;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String agentParticipantId(Models.Comment comment) {
|
||||
if (comment == null || !"agent".equalsIgnoreCase(clean(comment.authorType))) return "";
|
||||
return clean(comment.authorId);
|
||||
}
|
||||
|
||||
private static String agentName(String agentId, List<Models.Agent> agents) {
|
||||
if (agents != null) {
|
||||
for (Models.Agent agent : agents) {
|
||||
if (agentId.equals(agent.id)) return clean(agent.name);
|
||||
}
|
||||
}
|
||||
return Models.shortId(agentId);
|
||||
}
|
||||
|
||||
private static boolean isRoot(Models.Comment comment) {
|
||||
String parentId = clean(comment.parentId);
|
||||
return parentId.isEmpty() || "null".equalsIgnoreCase(parentId);
|
||||
}
|
||||
|
||||
private static int compareCreatedAt(Models.Comment left, Models.Comment right) {
|
||||
String leftCreatedAt = left == null ? "" : clean(left.createdAt);
|
||||
String rightCreatedAt = right == null ? "" : clean(right.createdAt);
|
||||
int compared = leftCreatedAt.compareTo(rightCreatedAt);
|
||||
if (compared != 0) return compared;
|
||||
return clean(left == null ? "" : left.id).compareTo(clean(right == null ? "" : right.id));
|
||||
}
|
||||
|
||||
private static String clean(String value) {
|
||||
if (value == null) return "";
|
||||
String trimmed = value.trim();
|
||||
return "null".equalsIgnoreCase(trimmed) ? "" : trimmed;
|
||||
}
|
||||
|
||||
private IssueCommentThreads() {
|
||||
}
|
||||
}
|
||||
|
||||
final class InboxNotificationDeduper {
|
||||
static List<Models.InboxItem> deduplicateByIssue(Collection<Models.InboxItem> items) {
|
||||
Map<String, Models.InboxItem> newestByIssue = new LinkedHashMap<>();
|
||||
|
||||
@@ -151,7 +151,6 @@ public final class MainActivity extends Activity {
|
||||
EditText email = input("you@example.com");
|
||||
email.setInputType(InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
|
||||
Button continueButton = button(t("continue"), BLUE, 0xFFFFFFFF);
|
||||
Button google = button(t("google"), 0xFFE5E7EB, TEXT);
|
||||
|
||||
root.addView(spacer(30));
|
||||
root.addView(mark);
|
||||
@@ -161,8 +160,6 @@ public final class MainActivity extends Activity {
|
||||
root.addView(email, matchWrap());
|
||||
root.addView(spacer(12));
|
||||
root.addView(continueButton, matchWrap());
|
||||
root.addView(spacer(12));
|
||||
root.addView(google, matchWrap());
|
||||
|
||||
continueButton.setOnClickListener(v -> {
|
||||
String value = email.getText().toString().trim();
|
||||
@@ -179,7 +176,6 @@ public final class MainActivity extends Activity {
|
||||
toast(t("sendCodeFailed"));
|
||||
});
|
||||
});
|
||||
google.setOnClickListener(v -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.MULTICA_WEB_BASE_URL + "/login"))));
|
||||
setContentView(root);
|
||||
}
|
||||
|
||||
@@ -2193,7 +2189,6 @@ public final class MainActivity extends Activity {
|
||||
case "signIn": return "登录 Multi-Casual";
|
||||
case "signInHint": return "输入邮箱获取登录验证码";
|
||||
case "continue": return "继续";
|
||||
case "google": return "使用 Google 继续";
|
||||
case "emailRequired": return "请输入邮箱";
|
||||
case "sendCodeFailed": return "验证码发送失败";
|
||||
case "otpTitle": return "输入验证码";
|
||||
@@ -2306,7 +2301,6 @@ public final class MainActivity extends Activity {
|
||||
case "signIn": return "Sign in to Multi-Casual";
|
||||
case "signInHint": return "Enter your email to get a login code";
|
||||
case "continue": return "Continue";
|
||||
case "google": return "Continue with Google";
|
||||
case "emailRequired": return "Email is required";
|
||||
case "sendCodeFailed": return "Failed to send code";
|
||||
case "otpTitle": return "Enter code";
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.outlined.AssignmentInd
|
||||
@@ -61,6 +62,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
@@ -1032,7 +1034,7 @@ fun MulticaCupertinoActionSheet(
|
||||
title: String,
|
||||
message: String? = null,
|
||||
items: List<MulticaCupertinoActionSheetItem>,
|
||||
cancelText: String,
|
||||
cancelText: String? = null,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
if (!visible) return
|
||||
@@ -1125,19 +1127,21 @@ fun MulticaCupertinoActionSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1184,19 +1188,23 @@ fun MulticaCupertinoAlertDialog(
|
||||
)
|
||||
},
|
||||
buttons = {
|
||||
action(
|
||||
onClick = {
|
||||
performMulticaTapFeedback(haptic)
|
||||
onDismissRequest()
|
||||
},
|
||||
style = AlertActionStyle.Cancel,
|
||||
enabled = true,
|
||||
) {
|
||||
Text(
|
||||
text = cancelText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium),
|
||||
color = MulticaColors.Accent,
|
||||
)
|
||||
if (!cancelText.isNullOrBlank()) {
|
||||
action(
|
||||
onClick = {
|
||||
performMulticaTapFeedback(haptic)
|
||||
onDismissRequest()
|
||||
},
|
||||
style = AlertActionStyle.Cancel,
|
||||
enabled = true,
|
||||
) {
|
||||
Text(
|
||||
text = cancelText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium),
|
||||
color = MulticaColors.Accent,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
action(
|
||||
onClick = {
|
||||
@@ -1208,8 +1216,10 @@ fun MulticaCupertinoAlertDialog(
|
||||
) {
|
||||
Text(
|
||||
text = confirmText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, fontWeight = FontWeight.SemiBold),
|
||||
color = if (destructive) MulticaColors.Danger else MulticaColors.Accent,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -1604,6 +1614,7 @@ fun MulticaCupertinoTextField(
|
||||
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||
) {
|
||||
val fieldDescription = contentDescription ?: "Multica Cupertino Text Field $label"
|
||||
val centerSingleLineContent = singleLine || minLines <= 1
|
||||
val fieldModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics { this.contentDescription = fieldDescription }
|
||||
@@ -1620,22 +1631,52 @@ fun MulticaCupertinoTextField(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
CupertinoBorderedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = fieldModifier.heightIn(min = if (singleLine) 44.dp else 48.dp),
|
||||
enabled = enabled,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
maxLines = maxLines,
|
||||
placeholder = {
|
||||
if (placeholder.isNotBlank()) {
|
||||
Text(placeholder, color = MulticaColors.TextTertiary)
|
||||
}
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp, lineHeight = 21.sp, color = MulticaColors.TextPrimary),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
val fieldShape = RoundedCornerShape(10.dp)
|
||||
val fieldTextStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 21.sp,
|
||||
color = if (enabled) MulticaColors.TextPrimary else MulticaColors.Muted,
|
||||
)
|
||||
Box(
|
||||
modifier = fieldModifier
|
||||
.heightIn(min = if (centerSingleLineContent) 44.dp else 48.dp)
|
||||
.clip(fieldShape)
|
||||
.background(if (enabled) MulticaColors.Surface else MulticaColors.Surface.copy(alpha = 0.56f))
|
||||
.border(1.dp, MulticaColors.Border.copy(alpha = if (enabled) 0.72f else 0.42f), fieldShape)
|
||||
.padding(horizontal = 12.dp, vertical = if (centerSingleLineContent) 0.dp else 10.dp),
|
||||
contentAlignment = if (centerSingleLineContent) Alignment.CenterStart else Alignment.TopStart,
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
enabled = enabled,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
maxLines = maxLines,
|
||||
textStyle = fieldTextStyle,
|
||||
cursorBrush = SolidColor(MulticaColors.Accent),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = if (centerSingleLineContent) Alignment.CenterStart else Alignment.TopStart,
|
||||
) {
|
||||
if (value.isEmpty() && placeholder.isNotBlank()) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = fieldTextStyle,
|
||||
color = MulticaColors.TextTertiary,
|
||||
maxLines = if (singleLine) 1 else maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2031,7 +2072,7 @@ fun MulticaErrorState(
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 13.sp, lineHeight = 17.sp),
|
||||
color = MulticaColors.Muted,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission
|
||||
android:name="com.google.android.gms.permission.AD_ID"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="freemme.permission.msa"
|
||||
tools:node="remove" />
|
||||
</manifest>
|
||||
|
||||
Reference in New Issue
Block a user