initial android app snapshot
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "org.jetbrains.kotlin.android"
|
||||
id "org.jetbrains.kotlin.plugin.compose"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "ai.multica.app"
|
||||
compileSdk 36
|
||||
buildToolsVersion "36.1.0"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "ai.multica.app"
|
||||
minSdk 24
|
||||
targetSdk 36
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation platform("androidx.compose:compose-bom:2024.10.01")
|
||||
implementation "androidx.activity:activity-compose:1.9.3"
|
||||
implementation "androidx.compose.foundation:foundation"
|
||||
implementation "androidx.compose.material3:material3"
|
||||
implementation "androidx.compose.material:material-icons-extended"
|
||||
implementation "androidx.compose.ui:ui"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview"
|
||||
implementation "io.github.alexzhirkevich:cupertino:0.1.0-alpha04"
|
||||
implementation "dev.chrisbanes.haze:haze:1.6.10"
|
||||
implementation "com.airbnb.android:lottie-compose:6.7.1"
|
||||
implementation "io.coil-kt:coil-compose:2.7.0"
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
testImplementation "org.json:json:20240303"
|
||||
androidTestImplementation "androidx.test:runner:1.6.2"
|
||||
androidTestImplementation "androidx.test.ext:junit:1.2.1"
|
||||
androidTestImplementation "androidx.test.uiautomator:uiautomator:2.3.0"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="false">
|
||||
<activity
|
||||
android:name=".ComposeVisualAuditActivity"
|
||||
android:theme="@style/ComposeAppTheme"
|
||||
android:exported="true" />
|
||||
<activity
|
||||
android:name=".ComposePilotActivity"
|
||||
android:theme="@style/ComposeAppTheme"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="multica" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
final class AuthStore {
|
||||
private static final String PREFS = "multica_auth";
|
||||
private static final String TOKEN = "token";
|
||||
private static final String WORKSPACE_ID = "workspace_id";
|
||||
private static final String LANGUAGE = "language";
|
||||
private static final String INBOX_CACHE_PREFIX = "inbox_cache_";
|
||||
private static final String CLOUDFRONT_COOKIE_HEADER = "cloudfront_cookie_header";
|
||||
|
||||
private final SharedPreferences prefs;
|
||||
|
||||
AuthStore(Context context) {
|
||||
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
String token() {
|
||||
return prefs.getString(TOKEN, null);
|
||||
}
|
||||
|
||||
void saveToken(String token) {
|
||||
prefs.edit().putString(TOKEN, token).apply();
|
||||
}
|
||||
|
||||
void clearToken() {
|
||||
prefs.edit().remove(TOKEN).remove(WORKSPACE_ID).remove(CLOUDFRONT_COOKIE_HEADER).apply();
|
||||
}
|
||||
|
||||
String workspaceId() {
|
||||
return prefs.getString(WORKSPACE_ID, null);
|
||||
}
|
||||
|
||||
void saveWorkspaceId(String workspaceId) {
|
||||
prefs.edit().putString(WORKSPACE_ID, workspaceId).apply();
|
||||
}
|
||||
|
||||
boolean isChinese() {
|
||||
return "zh".equals(prefs.getString(LANGUAGE, "en"));
|
||||
}
|
||||
|
||||
void setChinese(boolean enabled) {
|
||||
prefs.edit().putString(LANGUAGE, enabled ? "zh" : "en").apply();
|
||||
}
|
||||
|
||||
String inboxCache(String workspaceId) {
|
||||
if (workspaceId == null || workspaceId.trim().isEmpty()) return null;
|
||||
return prefs.getString(INBOX_CACHE_PREFIX + workspaceId, null);
|
||||
}
|
||||
|
||||
void saveInboxCache(String workspaceId, String json) {
|
||||
if (workspaceId == null || workspaceId.trim().isEmpty() || json == null || json.trim().isEmpty()) return;
|
||||
prefs.edit().putString(INBOX_CACHE_PREFIX + workspaceId, json).apply();
|
||||
}
|
||||
|
||||
String cloudFrontCookieHeader() {
|
||||
return prefs.getString(CLOUDFRONT_COOKIE_HEADER, "");
|
||||
}
|
||||
|
||||
void saveCloudFrontCookies(List<String> setCookieHeaders) {
|
||||
if (setCookieHeaders == null || setCookieHeaders.isEmpty()) return;
|
||||
Map<String, String> cookies = parseCookieHeader(cloudFrontCookieHeader());
|
||||
boolean changed = false;
|
||||
for (String header : setCookieHeaders) {
|
||||
if (header == null) continue;
|
||||
int semicolon = header.indexOf(';');
|
||||
String pair = (semicolon >= 0 ? header.substring(0, semicolon) : header).trim();
|
||||
int equals = pair.indexOf('=');
|
||||
if (equals <= 0) continue;
|
||||
String name = pair.substring(0, equals).trim();
|
||||
String value = pair.substring(equals + 1).trim();
|
||||
if (!name.startsWith("CloudFront-") || value.isEmpty()) continue;
|
||||
cookies.put(name, value);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return;
|
||||
prefs.edit().putString(CLOUDFRONT_COOKIE_HEADER, joinCookieHeader(cookies)).apply();
|
||||
}
|
||||
|
||||
private static Map<String, String> parseCookieHeader(String header) {
|
||||
Map<String, String> cookies = new LinkedHashMap<>();
|
||||
if (header == null || header.trim().isEmpty()) return cookies;
|
||||
String[] parts = header.split(";");
|
||||
for (String part : parts) {
|
||||
String pair = part == null ? "" : part.trim();
|
||||
int equals = pair.indexOf('=');
|
||||
if (equals <= 0) continue;
|
||||
cookies.put(pair.substring(0, equals).trim(), pair.substring(equals + 1).trim());
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
private static String joinCookieHeader(Map<String, String> cookies) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : cookies.entrySet()) {
|
||||
if (entry.getKey() == null || entry.getValue() == null || entry.getValue().isEmpty()) continue;
|
||||
if (sb.length() > 0) sb.append("; ");
|
||||
sb.append(entry.getKey()).append('=').append(entry.getValue());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
package ai.multica.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ai.multica.app.ui.components.MulticaButtonTone
|
||||
import ai.multica.app.ui.components.MulticaListRow
|
||||
import ai.multica.app.ui.components.MulticaPillButton
|
||||
import ai.multica.app.ui.components.MulticaShell
|
||||
import ai.multica.app.ui.components.MulticaTab
|
||||
import ai.multica.app.ui.components.StatusPill
|
||||
import ai.multica.app.ui.theme.MulticaColors
|
||||
import ai.multica.app.ui.theme.MulticaTheme
|
||||
|
||||
class ComposeVisualAuditActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
MulticaTheme {
|
||||
VisualAuditSandbox()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class InboxPreview(
|
||||
val type: String,
|
||||
val status: String,
|
||||
val title: String,
|
||||
val body: String,
|
||||
val unread: Boolean,
|
||||
)
|
||||
|
||||
private data class IssuePreview(
|
||||
val identifier: String,
|
||||
val title: String,
|
||||
val status: String,
|
||||
val priority: String,
|
||||
val assignee: String?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun VisualAuditSandbox() {
|
||||
val selected = remember { mutableStateOf(MulticaTab.Inbox) }
|
||||
MulticaShell(
|
||||
selectedTab = selected.value,
|
||||
workspaceName = "park0er",
|
||||
languageLabel = "中文",
|
||||
onLanguageClick = {},
|
||||
onWorkspaceClick = { selected.value = MulticaTab.Settings },
|
||||
onSearchClick = {},
|
||||
onChatClick = { selected.value = MulticaTab.Inbox },
|
||||
onTabClick = { selected.value = it },
|
||||
) {
|
||||
when (selected.value) {
|
||||
MulticaTab.Inbox -> InboxPreviewScreen()
|
||||
MulticaTab.Issues -> IssuesPreviewScreen(personal = false)
|
||||
MulticaTab.MyIssues -> IssuesPreviewScreen(personal = true)
|
||||
MulticaTab.Projects -> PlaceholderScreen("Projects", "Project list and detail will use the same list-row system.")
|
||||
MulticaTab.Settings -> PlaceholderScreen("Settings", "Settings will become grouped rows instead of a button stack.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InboxPreviewScreen() {
|
||||
val rows = listOf(
|
||||
InboxPreview(
|
||||
type = "New Comment",
|
||||
status = "Todo",
|
||||
title = "核心功能:Issue 列表 & 详情页",
|
||||
body = "已确认 Android 视觉修正回写:Inbox / Issues 的 pill 控件、白底细描边卡片、底部选中态和正文截断都比之前更干净。",
|
||||
unread = true,
|
||||
),
|
||||
InboxPreview(
|
||||
type = "Task Failed",
|
||||
status = "Todo",
|
||||
title = "核心功能:Issue 列表 & 详情页",
|
||||
body = "",
|
||||
unread = false,
|
||||
),
|
||||
InboxPreview(
|
||||
type = "New Comment",
|
||||
status = "Done",
|
||||
title = "Android Markdown table QA",
|
||||
body = "表格、代码块、引用和普通段落都应该使用同一套 Markdown 视觉系统。",
|
||||
unread = false,
|
||||
),
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
item {
|
||||
PageHeader(
|
||||
title = "Inbox",
|
||||
trailing = {
|
||||
MulticaPillButton("Chat", onClick = {}, tone = MulticaButtonTone.Secondary)
|
||||
},
|
||||
)
|
||||
}
|
||||
items(rows) { row ->
|
||||
MulticaListRow(
|
||||
eyebrow = "${row.type} · ${row.status}",
|
||||
title = row.title,
|
||||
subtitle = row.body.ifBlank { null },
|
||||
unread = row.unread,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IssuesPreviewScreen(personal: Boolean) {
|
||||
val rows = listOf(
|
||||
IssuePreview("PAR-62", "了解一下金事通 🍎", "Backlog", "Low", "ZhaoXishengGmail"),
|
||||
IssuePreview("PAR-68", "填写 W-8BEN 税务表格(税率 30%→10%)", "Backlog", "Low", null),
|
||||
IssuePreview("PAR-67", "绑定 Stripe 账户(Hong Kong / Individual)", "Backlog", "Low", null),
|
||||
IssuePreview("PAR-73", "核心功能:Issue 列表 & 详情页", "Todo", "High", "codex"),
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
item {
|
||||
PageHeader(
|
||||
title = if (personal) "My Issues" else "Issues",
|
||||
trailing = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MulticaPillButton("↓", onClick = {}, tone = MulticaButtonTone.Secondary)
|
||||
MulticaPillButton("Board", onClick = {}, tone = MulticaButtonTone.Ghost)
|
||||
MulticaPillButton("+", onClick = {}, tone = MulticaButtonTone.Primary)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = "Backlog (${rows.count { it.status == "Backlog" }})",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MulticaColors.Muted,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
items(rows) { issue ->
|
||||
IssuePreviewRow(issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IssuePreviewRow(issue: IssuePreview) {
|
||||
MulticaListRow(
|
||||
eyebrow = issue.identifier,
|
||||
title = issue.title,
|
||||
subtitle = buildString {
|
||||
append(issue.status)
|
||||
append(" · ")
|
||||
append(issue.priority)
|
||||
if (!issue.assignee.isNullOrBlank()) {
|
||||
append(" · ")
|
||||
append(issue.assignee)
|
||||
}
|
||||
},
|
||||
unread = false,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderScreen(title: String, description: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
PageHeader(title = title)
|
||||
StatusPill("Compose migration sandbox")
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MulticaColors.Muted,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PageHeader(
|
||||
title: String,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MulticaColors.Text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (trailing != null) {
|
||||
Spacer(modifier = Modifier.height(1.dp))
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
final class AgentMentionMarkdown {
|
||||
static String markdown(String name, String agentId) {
|
||||
String cleanName = name == null ? "" : name.trim()
|
||||
.replace("[", "\\[")
|
||||
.replace("]", "\\]");
|
||||
if (cleanName.isEmpty()) cleanName = Models.shortId(agentId);
|
||||
return "[@" + cleanName + "](mention://agent/" + agentId + ")";
|
||||
}
|
||||
|
||||
private AgentMentionMarkdown() {
|
||||
}
|
||||
}
|
||||
|
||||
final class InboxNotificationDeduper {
|
||||
static List<Models.InboxItem> deduplicateByIssue(Collection<Models.InboxItem> items) {
|
||||
Map<String, Models.InboxItem> newestByIssue = new LinkedHashMap<>();
|
||||
for (Models.InboxItem item : items) {
|
||||
if (item == null || item.archived) continue;
|
||||
String key = item.issueId == null || item.issueId.isEmpty() ? item.id : item.issueId;
|
||||
Models.InboxItem existing = newestByIssue.get(key);
|
||||
if (existing == null || compareCreatedAt(item, existing) > 0) {
|
||||
newestByIssue.put(key, item);
|
||||
}
|
||||
}
|
||||
ArrayList<Models.InboxItem> deduped = new ArrayList<>(newestByIssue.values());
|
||||
deduped.sort((left, right) -> compareCreatedAt(right, left));
|
||||
return deduped;
|
||||
}
|
||||
|
||||
private static int compareCreatedAt(Models.InboxItem left, Models.InboxItem right) {
|
||||
String leftCreatedAt = left.createdAt == null ? "" : left.createdAt;
|
||||
String rightCreatedAt = right.createdAt == null ? "" : right.createdAt;
|
||||
return leftCreatedAt.compareTo(rightCreatedAt);
|
||||
}
|
||||
|
||||
private InboxNotificationDeduper() {
|
||||
}
|
||||
}
|
||||
|
||||
final class SkillFileTree {
|
||||
static final class Node {
|
||||
final String name;
|
||||
final String path;
|
||||
final boolean directory;
|
||||
final Models.SkillFile file;
|
||||
final List<Node> children = new ArrayList<>();
|
||||
|
||||
Node(String name, String path, boolean directory, Models.SkillFile file) {
|
||||
this.name = name;
|
||||
this.path = path;
|
||||
this.directory = directory;
|
||||
this.file = file;
|
||||
}
|
||||
}
|
||||
|
||||
static List<Node> build(List<Models.SkillFile> files) {
|
||||
Node root = new Node("", "", true, null);
|
||||
for (Models.SkillFile file : files) {
|
||||
String normalized = normalizePath(file.path);
|
||||
if (normalized.isEmpty()) continue;
|
||||
String[] parts = normalized.split("/");
|
||||
Node parent = root;
|
||||
StringBuilder path = new StringBuilder();
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
if (parts[i].isEmpty()) continue;
|
||||
if (path.length() > 0) path.append('/');
|
||||
path.append(parts[i]);
|
||||
boolean leaf = i == parts.length - 1;
|
||||
Node existing = findChild(parent, parts[i], leaf ? path.toString() : path + "/");
|
||||
if (existing == null) {
|
||||
existing = new Node(parts[i], leaf ? path.toString() : path + "/", !leaf, leaf ? file : null);
|
||||
parent.children.add(existing);
|
||||
}
|
||||
parent = existing;
|
||||
}
|
||||
}
|
||||
sortRecursively(root.children, true);
|
||||
return root.children;
|
||||
}
|
||||
|
||||
static List<String> flattenDisplayPaths(List<Node> roots) {
|
||||
ArrayList<String> paths = new ArrayList<>();
|
||||
flattenInto(roots, paths);
|
||||
return paths;
|
||||
}
|
||||
|
||||
static boolean isMarkdownPath(String path) {
|
||||
String lower = normalizePath(path).toLowerCase(Locale.US);
|
||||
return lower.endsWith(".md") || lower.endsWith(".markdown");
|
||||
}
|
||||
|
||||
private static void flattenInto(List<Node> nodes, List<String> paths) {
|
||||
for (Node node : nodes) {
|
||||
paths.add(node.path);
|
||||
if (node.directory) flattenInto(node.children, paths);
|
||||
}
|
||||
}
|
||||
|
||||
private static Node findChild(Node parent, String name, String path) {
|
||||
for (Node child : parent.children) {
|
||||
if (child.name.equals(name) && child.path.equals(path)) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void sortRecursively(List<Node> nodes, boolean root) {
|
||||
for (Node node : nodes) {
|
||||
if (node.directory) sortRecursively(node.children, false);
|
||||
}
|
||||
nodes.sort(Comparator
|
||||
.comparingInt((Node node) -> sortBucket(node, root))
|
||||
.thenComparing(node -> node.name.toLowerCase(Locale.US)));
|
||||
}
|
||||
|
||||
private static int sortBucket(Node node, boolean root) {
|
||||
if (root && !node.directory && node.name.equalsIgnoreCase("SKILL.md")) return 0;
|
||||
if (node.directory) return 1;
|
||||
return root ? 2 : 3;
|
||||
}
|
||||
|
||||
private static String normalizePath(String path) {
|
||||
if (path == null) return "";
|
||||
return path.trim().replace('\\', '/').replaceAll("/+", "/").replaceAll("^/|/$", "");
|
||||
}
|
||||
|
||||
private SkillFileTree() {
|
||||
}
|
||||
}
|
||||
|
||||
final class ChatTimelineState {
|
||||
final String taskId;
|
||||
final String status;
|
||||
final boolean locallyPending;
|
||||
|
||||
private ChatTimelineState(String taskId, String status, boolean locallyPending) {
|
||||
this.taskId = taskId == null ? "" : taskId;
|
||||
this.status = status == null ? "" : status;
|
||||
this.locallyPending = locallyPending;
|
||||
}
|
||||
|
||||
static ChatTimelineState afterLocalSend(String taskId, String status) {
|
||||
return new ChatTimelineState(taskId, status == null || status.isEmpty() ? "queued" : status, true);
|
||||
}
|
||||
|
||||
boolean shouldShowPendingRow() {
|
||||
return locallyPending || !taskId.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
final class IssueBuckets {
|
||||
private IssueBuckets() {}
|
||||
|
||||
interface Fetcher {
|
||||
Models.Page<Models.Issue> fetch(String status, int limit, int offset) throws Exception;
|
||||
}
|
||||
|
||||
static List<Models.Issue> loadAll(Fetcher fetcher, int limit) throws Exception {
|
||||
List<Models.Issue> all = new ArrayList<>();
|
||||
for (String status : Models.STATUS_VALUES) {
|
||||
all.addAll(loadStatus(fetcher, status, limit));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
static List<Models.Issue> loadAllConcurrent(Fetcher fetcher, int limit) throws Exception {
|
||||
ExecutorService pool = Executors.newFixedThreadPool(Math.min(4, Models.STATUS_VALUES.length));
|
||||
try {
|
||||
List<Future<List<Models.Issue>>> futures = new ArrayList<>();
|
||||
for (String status : Models.STATUS_VALUES) {
|
||||
Callable<List<Models.Issue>> task = () -> loadStatus(fetcher, status, limit);
|
||||
futures.add(pool.submit(task));
|
||||
}
|
||||
List<Models.Issue> all = new ArrayList<>();
|
||||
for (Future<List<Models.Issue>> future : futures) all.addAll(future.get());
|
||||
return all;
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Models.Issue> loadStatus(Fetcher fetcher, String status, int limit) throws Exception {
|
||||
List<Models.Issue> issues = new ArrayList<>();
|
||||
int offset = 0;
|
||||
while (true) {
|
||||
Models.Page<Models.Issue> page = fetcher.fetch(status, limit, offset);
|
||||
issues.addAll(page.items);
|
||||
if (!page.hasMore || page.items.isEmpty()) break;
|
||||
offset += page.items.size();
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
final class IssueDetailLoader {
|
||||
interface Fetcher {
|
||||
Models.Issue issue(String issueId, String workspaceId) throws Exception;
|
||||
|
||||
List<Models.Comment> comments(String issueId, String workspaceId) throws Exception;
|
||||
|
||||
List<Models.AgentTask> runs(String issueId, String workspaceId) throws Exception;
|
||||
|
||||
List<Models.Project> projects() throws Exception;
|
||||
|
||||
List<Models.Member> members() throws Exception;
|
||||
|
||||
List<Models.Agent> agents() throws Exception;
|
||||
}
|
||||
|
||||
static Data load(String issueId, String workspaceId, Fetcher fetcher, int poolSize) throws Exception {
|
||||
ExecutorService pool = Executors.newFixedThreadPool(Math.max(1, poolSize));
|
||||
try {
|
||||
Future<Models.Issue> issueFuture = pool.submit(() -> fetcher.issue(issueId, workspaceId));
|
||||
Future<List<Models.Comment>> commentsFuture = null;
|
||||
Future<List<Models.AgentTask>> runsFuture = null;
|
||||
if (workspaceId != null && !workspaceId.isEmpty()) {
|
||||
commentsFuture = pool.submit(() -> fetcher.comments(issueId, workspaceId));
|
||||
runsFuture = pool.submit(() -> fetcher.runs(issueId, workspaceId));
|
||||
}
|
||||
Future<List<Models.Project>> projectsFuture = pool.submit(fetcher::projects);
|
||||
Future<List<Models.Member>> membersFuture = pool.submit(fetcher::members);
|
||||
Future<List<Models.Agent>> agentsFuture = pool.submit(fetcher::agents);
|
||||
|
||||
Data data = new Data();
|
||||
data.issue = get(issueFuture);
|
||||
String resolvedWorkspaceId = data.issue.workspaceId;
|
||||
if (resolvedWorkspaceId == null || resolvedWorkspaceId.isEmpty()) resolvedWorkspaceId = workspaceId;
|
||||
final String finalWorkspaceId = resolvedWorkspaceId;
|
||||
if (commentsFuture == null) commentsFuture = pool.submit(() -> fetcher.comments(issueId, finalWorkspaceId));
|
||||
if (runsFuture == null) runsFuture = pool.submit(() -> fetcher.runs(issueId, finalWorkspaceId));
|
||||
data.comments = get(commentsFuture);
|
||||
data.runs = get(runsFuture);
|
||||
data.projects = get(projectsFuture);
|
||||
data.members = get(membersFuture);
|
||||
data.agents = get(agentsFuture);
|
||||
return data;
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> T get(Future<T> future) throws Exception {
|
||||
try {
|
||||
return future.get();
|
||||
} catch (ExecutionException error) {
|
||||
Throwable cause = error.getCause();
|
||||
if (cause instanceof Exception) throw (Exception) cause;
|
||||
throw new RuntimeException(cause);
|
||||
}
|
||||
}
|
||||
|
||||
static final class Data {
|
||||
Models.Issue issue;
|
||||
List<Models.Comment> comments;
|
||||
List<Models.AgentTask> runs;
|
||||
List<Models.Project> projects;
|
||||
List<Models.Member> members;
|
||||
List<Models.Agent> agents;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class IssueDetailSectionOrder {
|
||||
enum Section {
|
||||
HERO,
|
||||
COMPACT_METADATA,
|
||||
DESCRIPTION,
|
||||
LATEST_PROGRESS,
|
||||
SUB_ISSUES,
|
||||
SUBSCRIBERS,
|
||||
COMMENTS,
|
||||
AGENT_WORK_DETAILS,
|
||||
MORE_DETAILS,
|
||||
USAGE,
|
||||
ACTIVITY
|
||||
}
|
||||
|
||||
static List<Section> defaultReadingPath(
|
||||
boolean hasAgentWork,
|
||||
boolean hasActivity
|
||||
) {
|
||||
ArrayList<Section> sections = new ArrayList<>();
|
||||
sections.add(Section.HERO);
|
||||
sections.add(Section.COMPACT_METADATA);
|
||||
sections.add(Section.DESCRIPTION);
|
||||
sections.add(Section.LATEST_PROGRESS);
|
||||
sections.add(Section.SUB_ISSUES);
|
||||
sections.add(Section.SUBSCRIBERS);
|
||||
sections.add(Section.COMMENTS);
|
||||
if (hasAgentWork) sections.add(Section.AGENT_WORK_DETAILS);
|
||||
sections.add(Section.MORE_DETAILS);
|
||||
sections.add(Section.USAGE);
|
||||
if (hasActivity) sections.add(Section.ACTIVITY);
|
||||
return sections;
|
||||
}
|
||||
|
||||
private IssueDetailSectionOrder() {
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Typeface;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.text.style.TypefaceSpan;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.View;
|
||||
import android.widget.HorizontalScrollView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TableLayout;
|
||||
import android.widget.TableRow;
|
||||
import android.widget.TextView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class MarkdownRenderer {
|
||||
private MarkdownRenderer() {}
|
||||
|
||||
static final class Palette {
|
||||
final int blockBackground;
|
||||
final int tableHeaderBackground;
|
||||
final int tableCellBackground;
|
||||
|
||||
private Palette(int blockBackground, int tableHeaderBackground, int tableCellBackground) {
|
||||
this.blockBackground = blockBackground;
|
||||
this.tableHeaderBackground = tableHeaderBackground;
|
||||
this.tableCellBackground = tableCellBackground;
|
||||
}
|
||||
}
|
||||
|
||||
static Palette paletteFor(int textColor, int mutedColor, int borderColor) {
|
||||
boolean darkSurface = luminance(textColor) > luminance(borderColor);
|
||||
if (darkSurface) {
|
||||
return new Palette(0xFF1C1C1E, 0xFF1C2638, 0xFF111113);
|
||||
}
|
||||
return new Palette(0xFFF3F4F6, 0xFFEFF6FF, 0xFFFFFFFF);
|
||||
}
|
||||
|
||||
static void render(Context context, LinearLayout parent, String markdown, int textColor, int mutedColor, int borderColor) {
|
||||
parent.removeAllViews();
|
||||
Palette palette = paletteFor(textColor, mutedColor, borderColor);
|
||||
if (markdown == null || markdown.trim().isEmpty()) {
|
||||
TextView empty = text(context, "", 15, textColor);
|
||||
parent.addView(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
String[] lines = markdown.replace("\r\n", "\n").split("\n", -1);
|
||||
List<String> paragraph = new ArrayList<>();
|
||||
boolean inCode = false;
|
||||
StringBuilder code = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String line = lines[i];
|
||||
if (line.trim().startsWith("```")) {
|
||||
flushParagraph(context, parent, paragraph, textColor);
|
||||
if (inCode) {
|
||||
addCodeBlock(context, parent, code.toString(), mutedColor, palette);
|
||||
code.setLength(0);
|
||||
inCode = false;
|
||||
} else {
|
||||
inCode = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
code.append(line).append('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isTableStart(lines, i)) {
|
||||
flushParagraph(context, parent, paragraph, textColor);
|
||||
List<String> table = new ArrayList<>();
|
||||
table.add(lines[i]);
|
||||
i += 2;
|
||||
while (i < lines.length && lines[i].trim().startsWith("|") && lines[i].contains("|")) {
|
||||
table.add(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i--;
|
||||
addTable(context, parent, table, textColor, mutedColor, borderColor, palette);
|
||||
continue;
|
||||
}
|
||||
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
flushParagraph(context, parent, paragraph, textColor);
|
||||
} else if (trimmed.matches("^(-{3,}|\\*{3,}|_{3,})$")) {
|
||||
flushParagraph(context, parent, paragraph, textColor);
|
||||
addDivider(context, parent, borderColor);
|
||||
} else if (trimmed.startsWith("#")) {
|
||||
flushParagraph(context, parent, paragraph, textColor);
|
||||
addHeading(context, parent, trimmed, textColor);
|
||||
} else if (trimmed.startsWith(">")) {
|
||||
flushParagraph(context, parent, paragraph, textColor);
|
||||
TextView quote = text(context, trimmed.replaceFirst("^>\\s?", ""), 15, mutedColor);
|
||||
quote.setPadding(dp(context, 10), dp(context, 6), dp(context, 8), dp(context, 6));
|
||||
quote.setBackgroundColor(palette.blockBackground);
|
||||
parent.addView(quote);
|
||||
} else if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
|
||||
flushParagraph(context, parent, paragraph, textColor);
|
||||
TextView bullet = text(context, "• " + trimmed.substring(2), 15, textColor);
|
||||
bullet.setPadding(dp(context, 8), dp(context, 2), 0, dp(context, 2));
|
||||
parent.addView(bullet);
|
||||
} else {
|
||||
paragraph.add(line);
|
||||
}
|
||||
}
|
||||
flushParagraph(context, parent, paragraph, textColor);
|
||||
if (code.length() > 0) addCodeBlock(context, parent, code.toString(), mutedColor, palette);
|
||||
}
|
||||
|
||||
private static boolean isTableStart(String[] lines, int index) {
|
||||
if (index + 1 >= lines.length) return false;
|
||||
String header = lines[index].trim();
|
||||
String divider = lines[index + 1].trim();
|
||||
return header.startsWith("|") && header.endsWith("|")
|
||||
&& divider.matches("^\\|?\\s*:?-{3,}:?\\s*(\\|\\s*:?-{3,}:?\\s*)+\\|?$");
|
||||
}
|
||||
|
||||
private static void flushParagraph(Context context, LinearLayout parent, List<String> paragraph, int textColor) {
|
||||
if (paragraph.isEmpty()) return;
|
||||
TextView p = text(context, String.join("\n", paragraph), 15, textColor);
|
||||
p.setText(applyInline(p.getText().toString()));
|
||||
p.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
p.setPadding(0, dp(context, 2), 0, dp(context, 6));
|
||||
parent.addView(p);
|
||||
paragraph.clear();
|
||||
}
|
||||
|
||||
private static void addHeading(Context context, LinearLayout parent, String raw, int textColor) {
|
||||
int level = 0;
|
||||
while (level < raw.length() && raw.charAt(level) == '#') level++;
|
||||
String title = raw.substring(level).trim();
|
||||
TextView h = text(context, title, level <= 1 ? 22 : level == 2 ? 19 : 17, textColor);
|
||||
h.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
h.setPadding(0, dp(context, 8), 0, dp(context, 4));
|
||||
parent.addView(h);
|
||||
}
|
||||
|
||||
private static void addCodeBlock(Context context, LinearLayout parent, String code, int mutedColor, Palette palette) {
|
||||
TextView tv = text(context, code.trim(), 13, mutedColor);
|
||||
tv.setTypeface(Typeface.MONOSPACE);
|
||||
tv.setPadding(dp(context, 10), dp(context, 8), dp(context, 10), dp(context, 8));
|
||||
tv.setBackgroundColor(palette.blockBackground);
|
||||
parent.addView(tv);
|
||||
}
|
||||
|
||||
private static void addDivider(Context context, LinearLayout parent, int borderColor) {
|
||||
View line = new View(context);
|
||||
line.setBackgroundColor(borderColor);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Math.max(1, dp(context, 1)));
|
||||
params.setMargins(0, dp(context, 8), 0, dp(context, 8));
|
||||
parent.addView(line, params);
|
||||
}
|
||||
|
||||
private static void addTable(Context context, LinearLayout parent, List<String> lines, int textColor, int mutedColor, int borderColor, Palette palette) {
|
||||
HorizontalScrollView scroll = new HorizontalScrollView(context);
|
||||
TableLayout table = new TableLayout(context);
|
||||
table.setShrinkAllColumns(false);
|
||||
table.setStretchAllColumns(false);
|
||||
|
||||
for (int r = 0; r < lines.size(); r++) {
|
||||
String[] cells = splitTable(lines.get(r));
|
||||
TableRow row = new TableRow(context);
|
||||
for (String cell : cells) {
|
||||
TextView tv = text(context, "", 14, r == 0 ? textColor : mutedColor);
|
||||
tv.setText(applyInline(cell.trim()));
|
||||
tv.setTypeface(r == 0 ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT);
|
||||
tv.setPadding(dp(context, 10), dp(context, 8), dp(context, 10), dp(context, 8));
|
||||
tv.setBackgroundColor(r == 0 ? palette.tableHeaderBackground : palette.tableCellBackground);
|
||||
row.addView(tv);
|
||||
}
|
||||
table.addView(row);
|
||||
}
|
||||
table.setBackgroundColor(borderColor);
|
||||
table.setPadding(1, 1, 1, 1);
|
||||
scroll.addView(table);
|
||||
scroll.setPadding(0, dp(context, 6), 0, dp(context, 8));
|
||||
parent.addView(scroll, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
}
|
||||
|
||||
static String[] splitTable(String line) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.startsWith("|")) trimmed = trimmed.substring(1);
|
||||
if (endsWithUnescapedPipe(trimmed)) trimmed = trimmed.substring(0, trimmed.length() - 1);
|
||||
|
||||
List<String> cells = new ArrayList<>();
|
||||
StringBuilder cell = new StringBuilder();
|
||||
boolean inCode = false;
|
||||
for (int i = 0; i < trimmed.length(); i++) {
|
||||
char ch = trimmed.charAt(i);
|
||||
if (ch == '`') {
|
||||
inCode = !inCode;
|
||||
cell.append(ch);
|
||||
} else if (ch == '\\' && i + 1 < trimmed.length() && trimmed.charAt(i + 1) == '|') {
|
||||
cell.append('|');
|
||||
i++;
|
||||
} else if (ch == '|' && !inCode) {
|
||||
cells.add(cell.toString());
|
||||
cell.setLength(0);
|
||||
} else {
|
||||
cell.append(ch);
|
||||
}
|
||||
}
|
||||
cells.add(cell.toString());
|
||||
return cells.toArray(new String[0]);
|
||||
}
|
||||
|
||||
private static boolean endsWithUnescapedPipe(String value) {
|
||||
if (!value.endsWith("|")) return false;
|
||||
int slashCount = 0;
|
||||
for (int i = value.length() - 2; i >= 0 && value.charAt(i) == '\\'; i--) {
|
||||
slashCount++;
|
||||
}
|
||||
return slashCount % 2 == 0;
|
||||
}
|
||||
|
||||
private static double luminance(int color) {
|
||||
int red = (color >> 16) & 0xff;
|
||||
int green = (color >> 8) & 0xff;
|
||||
int blue = color & 0xff;
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
}
|
||||
|
||||
private static TextView text(Context context, String value, int sp, int color) {
|
||||
TextView tv = new TextView(context);
|
||||
tv.setText(value);
|
||||
tv.setTextSize(sp);
|
||||
tv.setTextColor(color);
|
||||
tv.setLineSpacing(0, 1.08f);
|
||||
return tv;
|
||||
}
|
||||
|
||||
private static SpannableStringBuilder applyInline(String value) {
|
||||
SpannableStringBuilder out = new SpannableStringBuilder();
|
||||
int i = 0;
|
||||
while (i < value.length()) {
|
||||
if (value.startsWith("**", i)) {
|
||||
int end = value.indexOf("**", i + 2);
|
||||
if (end > i + 2) {
|
||||
int start = out.length();
|
||||
out.append(value, i + 2, end);
|
||||
out.setSpan(new StyleSpan(Typeface.BOLD), start, out.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (value.charAt(i) == '`') {
|
||||
int end = value.indexOf('`', i + 1);
|
||||
if (end > i + 1) {
|
||||
int start = out.length();
|
||||
out.append(value, i + 1, end);
|
||||
out.setSpan(new TypefaceSpan("monospace"), start, out.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.append(value.charAt(i));
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static int dp(Context context, int value) {
|
||||
return Math.round(value * context.getResources().getDisplayMetrics().density);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class OnboardingPayload {
|
||||
private OnboardingPayload() {}
|
||||
|
||||
static JSONObject questionnaire(String teamSize, String role, String useCase, String notes) throws Exception {
|
||||
JSONObject json = new JSONObject();
|
||||
putTrimmed(json, "team_size", teamSize);
|
||||
putTrimmed(json, "role", role);
|
||||
putTrimmed(json, "use_case", useCase);
|
||||
putTrimmed(json, "notes", notes);
|
||||
return json;
|
||||
}
|
||||
|
||||
static JSONObject starterContentPayload(String workspaceId, boolean assignToSelf) throws Exception {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("workspace_id", workspaceId);
|
||||
payload.put("project", new JSONObject()
|
||||
.put("title", "Getting Started with Multica")
|
||||
.put("description", "A lightweight starter project for learning Multica on mobile.")
|
||||
.put("icon", "sparkles"));
|
||||
payload.put("welcome_issue_template", new JSONObject()
|
||||
.put("title", "Welcome to Multica")
|
||||
.put("description", "Use this issue to test comments, Markdown, status changes, and agent collaboration.")
|
||||
.put("priority", "high"));
|
||||
payload.put("agent_guided_sub_issues", new JSONArray()
|
||||
.put(starterIssue(
|
||||
"Ask an agent to summarize your workspace",
|
||||
"Open the agent picker, assign this issue to an agent, and watch the activity thread update.",
|
||||
"todo",
|
||||
"high",
|
||||
assignToSelf))
|
||||
.put(starterIssue(
|
||||
"Review Markdown rendering",
|
||||
"Add a comment with a table, list, quote, and code block. Confirm the mobile detail page remains readable.",
|
||||
"todo",
|
||||
"medium",
|
||||
assignToSelf))
|
||||
.put(starterIssue(
|
||||
"Try Inbox triage",
|
||||
"Generate a notification, mark it read, then archive it from Inbox.",
|
||||
"todo",
|
||||
"medium",
|
||||
assignToSelf)));
|
||||
payload.put("self_serve_sub_issues", new JSONArray()
|
||||
.put(starterIssue(
|
||||
"Create your first issue",
|
||||
"Use New Issue to set project, status, priority, and assignee.",
|
||||
"todo",
|
||||
"high",
|
||||
assignToSelf))
|
||||
.put(starterIssue(
|
||||
"Organize issues by status",
|
||||
"Move an issue across the status groups and verify the list stays sorted.",
|
||||
"todo",
|
||||
"medium",
|
||||
assignToSelf))
|
||||
.put(starterIssue(
|
||||
"Invite a teammate or add an agent later",
|
||||
"When you are ready, open Settings to manage members, agents, and runtimes.",
|
||||
"todo",
|
||||
"low",
|
||||
assignToSelf)));
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static JSONObject starterIssue(
|
||||
String title,
|
||||
String description,
|
||||
String status,
|
||||
String priority,
|
||||
boolean assignToSelf) throws Exception {
|
||||
return new JSONObject()
|
||||
.put("title", title)
|
||||
.put("description", description)
|
||||
.put("status", status)
|
||||
.put("priority", priority)
|
||||
.put("assign_to_self", assignToSelf);
|
||||
}
|
||||
|
||||
private static void putTrimmed(JSONObject json, String key, String value) throws Exception {
|
||||
if (value == null) return;
|
||||
String trimmed = value.trim();
|
||||
if (!trimmed.isEmpty()) json.put(key, trimmed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
final class RuntimeSectionLoader {
|
||||
interface Loader<T> {
|
||||
T load() throws Exception;
|
||||
}
|
||||
|
||||
private RuntimeSectionLoader() {
|
||||
}
|
||||
|
||||
static <T> T load(String label, T fallback, List<String> loadErrors, Loader<T> loader) {
|
||||
try {
|
||||
return loader.load();
|
||||
} catch (Exception error) {
|
||||
loadErrors.add(label + ": " + (error.getMessage() == null ? error.toString() : error.getMessage()));
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
final class TranscriptRedactor {
|
||||
private static final Rule[] RULES = new Rule[] {
|
||||
new Rule("\\bAKIA[0-9A-Z]{16}\\b", "[REDACTED AWS KEY]"),
|
||||
new Rule("(?:aws_secret_access_key|secret_?access_?key)\\s*[=:]\\s*[A-Za-z0-9/+=]{40}", "[REDACTED AWS SECRET]", Pattern.CASE_INSENSITIVE),
|
||||
new Rule("-----BEGIN[A-Z\\s]*PRIVATE KEY-----[\\s\\S]*?-----END[A-Z\\s]*PRIVATE KEY-----", "[REDACTED PRIVATE KEY]"),
|
||||
new Rule("\\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\\b", "[REDACTED GITHUB TOKEN]"),
|
||||
new Rule("\\bglpat-[A-Za-z0-9_-]{20,}\\b", "[REDACTED GITLAB TOKEN]"),
|
||||
new Rule("\\bsk-[A-Za-z0-9_-]{20,}\\b", "[REDACTED API KEY]"),
|
||||
new Rule("\\bxox[bporas]-[A-Za-z0-9-]{10,}\\b", "[REDACTED SLACK TOKEN]"),
|
||||
new Rule("\\bey[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b", "[REDACTED JWT]"),
|
||||
new Rule("\\bBearer\\s+[A-Za-z0-9\\-._~+/]+=*", "Bearer [REDACTED]", Pattern.CASE_INSENSITIVE),
|
||||
new Rule("(?:postgres|mysql|mongodb|redis|amqp)(?:ql)?://[^:\\s]+:[^@\\s]+@", "[REDACTED CONNECTION STRING]@", Pattern.CASE_INSENSITIVE),
|
||||
new Rule("(?:API_KEY|API_SECRET|SECRET_KEY|SECRET|ACCESS_TOKEN|AUTH_TOKEN|PRIVATE_KEY|DATABASE_URL|DB_PASSWORD|DB_URL|REDIS_URL|PASSWORD|TOKEN)\\s*[=:]\\s*(?!\\[REDACTED)\\S+", "[REDACTED CREDENTIAL]", Pattern.CASE_INSENSITIVE)
|
||||
};
|
||||
|
||||
private TranscriptRedactor() {
|
||||
}
|
||||
|
||||
static String redactSecrets(String text) {
|
||||
if (text == null || text.isEmpty()) return "";
|
||||
String result = text;
|
||||
for (Rule rule : RULES) {
|
||||
result = rule.pattern.matcher(result).replaceAll(rule.replacement);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static final class Rule {
|
||||
final Pattern pattern;
|
||||
final String replacement;
|
||||
|
||||
Rule(String regex, String replacement) {
|
||||
this(regex, replacement, 0);
|
||||
}
|
||||
|
||||
Rule(String regex, String replacement, int flags) {
|
||||
this.pattern = Pattern.compile(regex, flags);
|
||||
this.replacement = replacement;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
final class WorkspaceSelection {
|
||||
private WorkspaceSelection() {
|
||||
}
|
||||
|
||||
static Models.Workspace chooseWorkspace(List<Models.Workspace> workspaces, String preferredWorkspaceId) {
|
||||
if (workspaces == null || workspaces.isEmpty()) return null;
|
||||
if (preferredWorkspaceId != null && !preferredWorkspaceId.isEmpty()) {
|
||||
for (Models.Workspace workspace : workspaces) {
|
||||
if (preferredWorkspaceId.equals(workspace.id)) return workspace;
|
||||
}
|
||||
}
|
||||
return workspaces.get(0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,230 @@
|
||||
package ai.multica.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.github.alexzhirkevich.cupertino.ExperimentalCupertinoApi
|
||||
import io.github.alexzhirkevich.cupertino.theme.CupertinoTheme
|
||||
|
||||
object MulticaColors {
|
||||
var Accent = Color(0xFF2563EB)
|
||||
private set
|
||||
var AccentSoft = Color(0xFF1C2638)
|
||||
private set
|
||||
var Background = Color.Black
|
||||
private set
|
||||
var GroupedBackground = Color.Black
|
||||
private set
|
||||
var Surface = Color(0xFF1C1C1E)
|
||||
private set
|
||||
var SurfaceElevated = Color(0xFF242426)
|
||||
private set
|
||||
var Text = Color(0xFFF5F5F7)
|
||||
private set
|
||||
var TextPrimary = Color(0xFFF5F5F7)
|
||||
private set
|
||||
var TextSecondary = Color(0xFFA1A1AA)
|
||||
private set
|
||||
var TextTertiary = Color(0xFF71717A)
|
||||
private set
|
||||
var Muted = Color(0xFF8E8E93)
|
||||
private set
|
||||
var Border = Color(0xFF2C2C2E)
|
||||
private set
|
||||
var Success = Color(0xFF16A34A)
|
||||
private set
|
||||
var Danger = Color(0xFFFF453A)
|
||||
private set
|
||||
var Warning = Color(0xFFFF9F0A)
|
||||
private set
|
||||
|
||||
fun applyDarkTheme(darkTheme: Boolean) {
|
||||
Accent = Color(0xFF2563EB)
|
||||
Success = Color(0xFF16A34A)
|
||||
Danger = if (darkTheme) Color(0xFFFF453A) else Color(0xFFDC2626)
|
||||
Warning = if (darkTheme) Color(0xFFFF9F0A) else Color(0xFFD97706)
|
||||
if (darkTheme) {
|
||||
AccentSoft = Color(0xFF1C2638)
|
||||
Background = Color.Black
|
||||
GroupedBackground = Color(0xFF0B0B0D)
|
||||
Surface = Color(0xFF1C1C1E)
|
||||
SurfaceElevated = Color(0xFF242426)
|
||||
TextPrimary = Color(0xFFF5F5F7)
|
||||
TextSecondary = Color(0xFFA1A1AA)
|
||||
TextTertiary = Color(0xFF71717A)
|
||||
Text = TextPrimary
|
||||
Muted = TextSecondary
|
||||
Border = Color(0xFF2C2C2E)
|
||||
} else {
|
||||
AccentSoft = Color(0xFFEAF2FF)
|
||||
Background = Color(0xFFF6F7F9)
|
||||
GroupedBackground = Color(0xFFF2F3F6)
|
||||
Surface = Color.White
|
||||
SurfaceElevated = Color.White
|
||||
TextPrimary = Color(0xFF111827)
|
||||
TextSecondary = Color(0xFF6B7280)
|
||||
TextTertiary = Color(0xFF9CA3AF)
|
||||
Text = TextPrimary
|
||||
Muted = TextSecondary
|
||||
Border = Color(0xFFE5E7EB)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class MulticaSpacing(
|
||||
val page: Dp = 18.dp,
|
||||
val pageHorizontal: Dp = 20.dp,
|
||||
val pageTop: Dp = 28.dp,
|
||||
val sectionTop: Dp = 22.dp,
|
||||
val rowGap: Dp = 10.dp,
|
||||
val rowPaddingHorizontal: Dp = 14.dp,
|
||||
val rowPaddingVertical: Dp = 12.dp,
|
||||
val compactRowVertical: Dp = 9.dp,
|
||||
val controlHeight: Dp = 44.dp,
|
||||
val smallControlHeight: Dp = 34.dp,
|
||||
val bottomBarHorizontal: Dp = 16.dp,
|
||||
val pillHorizontal: Dp = 12.dp,
|
||||
val pillVertical: Dp = 7.dp,
|
||||
)
|
||||
|
||||
val LocalMulticaSpacing = staticCompositionLocalOf { MulticaSpacing() }
|
||||
|
||||
private fun multicaLightScheme(): ColorScheme = lightColorScheme(
|
||||
primary = MulticaColors.Accent,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = MulticaColors.AccentSoft,
|
||||
onPrimaryContainer = MulticaColors.Accent,
|
||||
background = MulticaColors.Background,
|
||||
onBackground = MulticaColors.Text,
|
||||
surface = MulticaColors.Surface,
|
||||
onSurface = MulticaColors.Text,
|
||||
surfaceVariant = Color(0xFFF3F4F6),
|
||||
onSurfaceVariant = MulticaColors.Muted,
|
||||
outline = MulticaColors.Border,
|
||||
error = MulticaColors.Danger,
|
||||
onError = Color.White,
|
||||
)
|
||||
|
||||
private fun multicaDarkScheme(): ColorScheme = darkColorScheme(
|
||||
primary = MulticaColors.Accent,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = MulticaColors.AccentSoft,
|
||||
onPrimaryContainer = Color(0xFF7DB1FF),
|
||||
background = MulticaColors.Background,
|
||||
onBackground = MulticaColors.Text,
|
||||
surface = MulticaColors.Surface,
|
||||
onSurface = MulticaColors.Text,
|
||||
surfaceVariant = Color(0xFF242426),
|
||||
onSurfaceVariant = MulticaColors.Muted,
|
||||
outline = MulticaColors.Border,
|
||||
error = MulticaColors.Danger,
|
||||
onError = Color.White,
|
||||
)
|
||||
|
||||
private val largeTitle = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 34.sp,
|
||||
lineHeight = 40.sp,
|
||||
)
|
||||
|
||||
private val pageTitle = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 34.sp,
|
||||
)
|
||||
|
||||
private val rowTitle = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 17.sp,
|
||||
lineHeight = 22.sp,
|
||||
)
|
||||
|
||||
private val bodyText = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 23.sp,
|
||||
)
|
||||
|
||||
private val subheadline = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
)
|
||||
|
||||
private val captionText = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 17.sp,
|
||||
)
|
||||
|
||||
private val eyebrowText = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 14.sp,
|
||||
)
|
||||
|
||||
private val multicaTypography = Typography(
|
||||
displaySmall = largeTitle,
|
||||
headlineMedium = pageTitle,
|
||||
titleMedium = rowTitle,
|
||||
bodyLarge = bodyText,
|
||||
bodyMedium = subheadline,
|
||||
bodySmall = captionText,
|
||||
labelMedium = eyebrowText,
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
),
|
||||
)
|
||||
|
||||
private val multicaShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(8.dp),
|
||||
small = RoundedCornerShape(12.dp),
|
||||
medium = RoundedCornerShape(14.dp),
|
||||
large = RoundedCornerShape(18.dp),
|
||||
extraLarge = RoundedCornerShape(24.dp),
|
||||
)
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalCupertinoApi::class)
|
||||
fun MulticaTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MulticaColors.applyDarkTheme(darkTheme)
|
||||
CupertinoTheme(
|
||||
content = {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) multicaDarkScheme() else multicaLightScheme(),
|
||||
typography = multicaTypography,
|
||||
shapes = multicaShapes,
|
||||
content = content,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Multica</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,20 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:statusBarColor">#FFFFFF</item>
|
||||
<item name="android:navigationBarColor">#FFFFFF</item>
|
||||
<item name="android:colorAccent">#2563EB</item>
|
||||
</style>
|
||||
|
||||
<style name="ComposeAppTheme" parent="android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:statusBarColor">#000000</item>
|
||||
<item name="android:navigationBarColor">#000000</item>
|
||||
<item name="android:windowBackground">#000000</item>
|
||||
<item name="android:windowSplashScreenBackground">#000000</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">#000000</item>
|
||||
<item name="android:colorAccent">#2563EB</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user