feat(android): chat base feature (#12684)

- **feat(android): chat send & receive**
- **[WIP] feat(android): markdown style for chat**
- **fix(android): fix auto scroll & ai message id replacement**
- **feat(android): replace icons**
- **refactor(android): design system**
- **feat(android): markdown style for chat**

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced a comprehensive custom theme system with new color palettes, typography, and theme modes (Light, Dark, System).
  - Added support for rendering Markdown-formatted text in chat messages with custom styling.
  - Integrated new vector icons for UI elements such as lists, camera, image, send, close, and more.
  - Added composable icon and icon button components for consistent icon usage across the app.

- **Enhancements**
  - Updated chat UI to use the new theme, icons, and Markdown rendering for AI messages.
  - Improved chat message management and send button state handling with enhanced session retrieval and SSE stream processing.
  - Refined app bar and dropdown menu components with updated icons and theme integration.
  - Enhanced floating action button appearance with tinted vector drawable.
  - Unified UI components and styling under the AFFiNE design system in chat input and app bars.

- **Bug Fixes**
  - Corrected application and theme class naming for consistency.

- **Chores**
  - Added new dependencies for rich text and Markdown support.
  - Updated color and icon resources for a unified visual style.
  - Removed deprecated headers from authentication requests.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
aki-chang-dev
2025-06-03 07:35:47 +00:00
parent ab78b8e3ab
commit a02eed382d
38 changed files with 1120 additions and 254 deletions

View File

@@ -128,6 +128,9 @@ dependencies {
implementation libs.okhttp.logging implementation libs.okhttp.logging
implementation libs.okhttp.sse implementation libs.okhttp.sse
implementation libs.richtext.commonmark
implementation libs.richtext.ui
testImplementation libs.junit testImplementation libs.junit
androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core androidTestImplementation libs.androidx.espresso.core

View File

@@ -8,7 +8,7 @@
</queries> </queries>
<application <application
android:name=".AffineApp" android:name=".AFFiNEApp"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"

View File

@@ -13,7 +13,7 @@ import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber import timber.log.Timber
@HiltAndroidApp @HiltAndroidApp
class AffineApp : Application() { class AFFiNEApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()

View File

@@ -24,9 +24,9 @@ object AuthInitializer {
MainScope().launch(Dispatchers.IO) { MainScope().launch(Dispatchers.IO) {
try { try {
val server = bridge.getCurrentServerBaseUrl().toHttpUrl() val server = bridge.getCurrentServerBaseUrl().toHttpUrl()
val sessionCookieStr = AffineApp.context().dataStore val sessionCookieStr = AFFiNEApp.context().dataStore
.get(server.host + CookieStore.AFFINE_SESSION) .get(server.host + CookieStore.AFFINE_SESSION)
val userIdCookieStr = AffineApp.context().dataStore val userIdCookieStr = AFFiNEApp.context().dataStore
.get(server.host + CookieStore.AFFINE_USER_ID) .get(server.host + CookieStore.AFFINE_USER_ID)
if (sessionCookieStr.isEmpty() || userIdCookieStr.isEmpty()) { if (sessionCookieStr.isEmpty() || userIdCookieStr.isEmpty()) {
Timber.i("[init] user has not signed in yet.") Timber.i("[init] user has not signed in yet.")

View File

@@ -6,13 +6,15 @@ import android.view.Gravity
import android.view.View import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateMargins import androidx.core.view.updateMargins
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import app.affine.pro.ai.AIActivity import app.affine.pro.ai.AIActivity
import app.affine.pro.plugin.AIButtonPlugin import app.affine.pro.plugin.AIButtonPlugin
import app.affine.pro.plugin.AffineThemePlugin import app.affine.pro.plugin.AFFiNEThemePlugin
import app.affine.pro.plugin.AuthPlugin import app.affine.pro.plugin.AuthPlugin
import app.affine.pro.plugin.HashCashPlugin import app.affine.pro.plugin.HashCashPlugin
import app.affine.pro.plugin.NbStorePlugin import app.affine.pro.plugin.NbStorePlugin
@@ -29,7 +31,7 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AffineThemePlugin.Callback, class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugin.Callback,
View.OnClickListener { View.OnClickListener {
@Inject @Inject
@@ -44,7 +46,7 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AffineThemePlugi
init { init {
registerPlugins( registerPlugins(
listOf( listOf(
AffineThemePlugin::class.java, AFFiNEThemePlugin::class.java,
AIButtonPlugin::class.java, AIButtonPlugin::class.java,
AuthPlugin::class.java, AuthPlugin::class.java,
HashCashPlugin::class.java, HashCashPlugin::class.java,
@@ -62,6 +64,13 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AffineThemePlugi
} }
customSize = dp2px(52) customSize = dp2px(52)
setImageResource(R.drawable.ic_ai) setImageResource(R.drawable.ic_ai)
setImageDrawable(
VectorDrawableCompat.create(resources, R.drawable.ic_ai, theme)?.apply {
DrawableCompat.setTint(
this,
ContextCompat.getColor(context, R.color.affine_primary)
)
})
setOnClickListener(this@MainActivity) setOnClickListener(this@MainActivity)
val parent = bridge.webView.parent as CoordinatorLayout val parent = bridge.webView.parent as CoordinatorLayout
parent.addView(this) parent.addView(this)

View File

@@ -2,12 +2,12 @@ package app.affine.pro.ai
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@@ -26,19 +26,24 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.affine.pro.ai.chat.ChatViewModel import app.affine.pro.ai.chat.ChatViewModel
import app.affine.pro.ai.chat.MessageUiState
import app.affine.pro.ai.chat.ui.ChatAppBar import app.affine.pro.ai.chat.ui.ChatAppBar
import app.affine.pro.ai.chat.ui.Message import app.affine.pro.ai.chat.ui.Message
import app.affine.pro.ai.chat.ui.UserInput import app.affine.pro.ai.chat.ui.UserInput
import app.affine.pro.theme.AffineTheme import app.affine.pro.theme.AFFiNETheme
import app.affine.pro.theme.ThemeMode
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -61,7 +66,7 @@ class AIActivity : AppCompatActivity() {
val scrollState = rememberLazyListState() val scrollState = rememberLazyListState()
val topBarState = rememberTopAppBarState() val topBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState)
AffineTheme(isDarkTheme = true) { AFFiNETheme(mode = ThemeMode.Dark) {
Scaffold( Scaffold(
topBar = { topBar = {
ChatAppBar( ChatAppBar(
@@ -74,47 +79,63 @@ class AIActivity : AppCompatActivity() {
.exclude(WindowInsets.navigationBars) .exclude(WindowInsets.navigationBars)
.exclude(WindowInsets.ime), .exclude(WindowInsets.ime),
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = AFFiNETheme.colors.backgroundPrimary,
) { paddingValues -> ) { paddingValues ->
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val messageUiState by viewModel.messagesUiState.collectAsStateWithLifecycle()
val sendBtnEnable by viewModel.sendBtnUiState.collectAsStateWithLifecycle()
var previousMessageCount by remember { mutableIntStateOf(0) }
var isAtBottom by remember { mutableStateOf(true) }
LaunchedEffect(messageUiState.messages) {
val currentMessageCount = messageUiState.messages.size
if (isAtBottom && currentMessageCount >= previousMessageCount) {
scrollState.animateScrollToItem(0)
}
previousMessageCount = currentMessageCount
}
SideEffect {
isAtBottom = messageUiState.messages.isEmpty()
|| scrollState.firstVisibleItemIndex == 0
&& scrollState.firstVisibleItemScrollOffset == 0
}
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
Box(Modifier.weight(1f)) { Box(Modifier.weight(1f)) {
with(uiState) { with(messageUiState) {
when { LazyColumn(
this is MessageUiState -> LazyColumn( reverseLayout = true,
reverseLayout = true, state = scrollState,
state = scrollState, modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize(), ) {
) { items(
items( items = messages,
items = messages, key = { it.id ?: "" },
key = { it.id ?: "" }, contentType = { it.role }
contentType = { it.role } ) { message ->
) { message -> Message(message)
Message(message)
}
} }
} }
} }
} }
val context = LocalContext.current
UserInput( UserInput(
modifier = Modifier
.navigationBarsPadding()
.imePadding(),
onMessageSent = { content -> onMessageSent = { content ->
Toast.makeText(context, "Not implemented.", Toast.LENGTH_SHORT) viewModel.sendMessage(content)
.show()
// viewModel.sendMessage(content)
}, },
sendMessageEnabled = sendBtnEnable,
resetScroll = { resetScroll = {
scope.launch { scope.launch {
scrollState.scrollToItem(0) scrollState.scrollToItem(0)
} }
}, },
modifier = Modifier
.navigationBarsPadding()
.imePadding()
) )
} }
} }

View File

@@ -1,13 +1,19 @@
package app.affine.pro.ai.chat package app.affine.pro.ai.chat
import com.affine.pro.graphql.GetCopilotHistoriesQuery import com.affine.pro.graphql.GetCopilotHistoriesQuery
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
sealed class ChatUiState data class MessageUiState(val messages: List<ChatMessage>) {
data class MessageUiState( fun addMessage(message: ChatMessage) = MessageUiState(messages = listOf(message) + messages)
val messages: List<ChatMessage>
) : ChatUiState() fun updateMessageAt(index: Int, transformer: (ChatMessage) -> ChatMessage) = MessageUiState(
messages = messages.toMutableList().apply {
add(index, transformer(removeAt(index)))
}
)
}
data class ChatMessage( data class ChatMessage(
val id: String?, val id: String?,
@@ -27,11 +33,29 @@ data class ChatMessage(
} }
companion object { companion object {
const val AI_LOCAL_ID = "ai_local_id"
const val USER_LOCAL_ID = "user_local_id"
fun newUserMessage(id: String, content: String) = ChatMessage(
id = id,
role = Role.User,
content = content,
createAt = Clock.System.now(),
)
fun newAIMessage() = ChatMessage(
id = AI_LOCAL_ID,
role = Role.AI,
content = "",
createAt = Clock.System.now(),
)
fun from(message: GetCopilotHistoriesQuery.Message) = ChatMessage( fun from(message: GetCopilotHistoriesQuery.Message) = ChatMessage(
id = message.id, id = message.id,
role = Role.fromValue(message.role), role = Role.fromValue(message.role),
content = message.content, content = message.content,
createAt = message.createdAt createAt = message.createdAt,
) )
} }
} }

View File

@@ -1,7 +1,9 @@
package app.affine.pro.ai.chat package app.affine.pro.ai.chat
import android.widget.Toast
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.affine.pro.AFFiNEApp
import app.affine.pro.service.GraphQLService import app.affine.pro.service.GraphQLService
import app.affine.pro.service.SSEService import app.affine.pro.service.SSEService
import app.affine.pro.service.WebService import app.affine.pro.service.WebService
@@ -9,9 +11,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -25,20 +30,28 @@ class ChatViewModel @Inject constructor(
private lateinit var sessionId: String private lateinit var sessionId: String
private val _uiState: MutableStateFlow<ChatUiState> = private val _messagesUiState: MutableStateFlow<MessageUiState> =
MutableStateFlow(MessageUiState(emptyList())) MutableStateFlow(MessageUiState(emptyList()))
val uiState: StateFlow<ChatUiState> = _uiState private val _sendBtnUiState: MutableStateFlow<Boolean> = MutableStateFlow(false)
val messagesUiState: StateFlow<MessageUiState> = _messagesUiState
val sendBtnUiState: StateFlow<Boolean> = _sendBtnUiState
init { init {
viewModelScope.launch { viewModelScope.launch {
sessionId = graphQLService.createCopilotSession( sessionId = graphQLService.getCopilotSession(
webService.workspaceId(),
webService.docId()
).getOrNull() ?: graphQLService.createCopilotSession(
workspaceId = webService.workspaceId(), workspaceId = webService.workspaceId(),
docId = webService.docId(), docId = webService.docId(),
).getOrElse { ).getOrElse {
Timber.w(it, "Create session failed") Timber.w(it, "Create session failed")
return@launch return@launch
} }
_sendBtnUiState.value = true
Timber.i("Create session success:[ sessionId = $sessionId].") Timber.i("Create session success:[ sessionId = $sessionId].")
val historyMessages = graphQLService.getCopilotHistories( val historyMessages = graphQLService.getCopilotHistories(
workspaceId = webService.workspaceId(), workspaceId = webService.workspaceId(),
@@ -49,40 +62,61 @@ class ChatViewModel @Inject constructor(
}.sortedByDescending { }.sortedByDescending {
it.createAt it.createAt
} }
_uiState.value = MessageUiState(historyMessages) _messagesUiState.value = MessageUiState(historyMessages)
} }
} }
fun sendMessage(message: String) { fun sendMessage(message: String) {
val sendMessage = suspend { viewModelScope.launch {
graphQLService.createCopilotMessage( _sendBtnUiState.value = false
val messageId = graphQLService.createCopilotMessage(
sessionId = sessionId, sessionId = sessionId,
message = message, message = message,
).onSuccess { messageId -> ).getOrElse {
Timber.i("send message: $messageId") Timber.e(it, "Send message fail.")
sseService.messageStream(sessionId, messageId) Toast.makeText(AFFiNEApp.context(), "Loading.", Toast.LENGTH_SHORT).show()
.onEach { return@launch
Timber.d("On sse message: ${it.getOrNull()}") }
Timber.i("Send message: $messageId")
_messagesUiState.update {
it.addMessage(ChatMessage.newUserMessage(messageId, message))
}
sseService.messageStream(sessionId, messageId)
.onStart {
_messagesUiState.update {
it.addMessage(ChatMessage.newAIMessage())
}
}
.onEach {
val event = it.getOrThrow()
Timber.d("On sse event: $event")
when (event.type) {
"message" -> _messagesUiState.update { state ->
state.updateMessageAt(0) { message ->
message.copy(content = message.content + event.data)
}
}
"error" -> Timber.e(event.data)
}
}
.catch {
Timber.e(it, "Receive message fail.")
}
.flowOn(Dispatchers.IO)
.collect()
val ids = graphQLService.getCopilotHistoryIds(
webService.workspaceId(),
webService.docId(),
sessionId,
).getOrElse { emptyList() }
if (ids.isNotEmpty() && ids.size == _messagesUiState.value.messages.size) {
_messagesUiState.update { state ->
state.updateMessageAt(0) { message ->
message.copy(id = ids.last().id)
} }
.flowOn(Dispatchers.IO)
.collect()
}
}
viewModelScope.launch {
if (!this@ChatViewModel::sessionId.isInitialized) {
graphQLService.getCopilotSession(
workspaceId = webService.workspaceId(),
docId = webService.docId(),
).onSuccess { id ->
sessionId = id
Timber.i("Create session: $id")
sendMessage()
}.onFailure {
Timber.e(it, "Create session failed.")
} }
} else {
sendMessage()
} }
_sendBtnUiState.value = true
} }
} }
} }

View File

@@ -1,25 +1,21 @@
package app.affine.pro.ai.chat.ui package app.affine.pro.ai.chat.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.affine.pro.components.AffineAppBar import androidx.compose.ui.unit.sp
import app.affine.pro.components.AffineDropMenu import app.affine.pro.R
import app.affine.pro.components.AFFiNEAppBar
import app.affine.pro.components.AFFiNEDropMenu
import app.affine.pro.components.AFFiNEIcon
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -30,36 +26,33 @@ fun ChatAppBar(
onClearHistory: () -> Unit = { }, onClearHistory: () -> Unit = { },
onSaveAsChatBlock: () -> Unit = { }, onSaveAsChatBlock: () -> Unit = { },
) { ) {
AffineAppBar( AFFiNEAppBar(
modifier = modifier, modifier = modifier,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
onNavIconPressed = onBackClick, onNavIconPressed = onBackClick,
title = { title = {
Row( Row(
modifier = Modifier.clickable { },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Chat with AI") Text("Chat with AI", fontSize = 17.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.width(10.dp))
Icon(imageVector = Icons.Default.KeyboardArrowDown, contentDescription = null)
} }
}, },
actions = { actions = {
AffineDropMenu( AFFiNEDropMenu(
icon = { Icon(Icons.Default.MoreHoriz, contentDescription = "More actions") }, R.drawable.ic_more_horizontal,
modifier = Modifier.size(44.dp),
menuItems = { menuItems = {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Clear history") }, text = { Text("Clear history") },
trailingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }, trailingIcon = {
AFFiNEIcon(R.drawable.ic_broom)
},
onClick = onClearHistory, onClick = onClearHistory,
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Save as chat block") }, text = { Text("Save as chat block") },
trailingIcon = { trailingIcon = {
Icon( AFFiNEIcon(R.drawable.ic_bubble)
Icons.Default.ChatBubble,
contentDescription = null
)
}, },
onClick = onSaveAsChatBlock, onClick = onSaveAsChatBlock,
) )

View File

@@ -7,23 +7,22 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import app.affine.pro.R import app.affine.pro.R
import app.affine.pro.ai.chat.ChatMessage import app.affine.pro.ai.chat.ChatMessage
import app.affine.pro.components.AFFiNEIcon
import app.affine.pro.components.Markdown
import app.affine.pro.theme.AFFiNETheme
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
@Composable @Composable
@@ -44,11 +43,9 @@ fun Message(message: ChatMessage) {
.padding(8.dp) .padding(8.dp)
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( AFFiNEIcon(
imageVector = ImageVector.vectorResource(R.drawable.ic_ai), R.drawable.ic_ai,
contentDescription = null, tint = AFFiNETheme.colors.iconActivated
tint = Color(0XFF1E96EB),
modifier = Modifier.size(24.dp)
) )
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text( Text(
@@ -56,17 +53,21 @@ fun Message(message: ChatMessage) {
ChatMessage.Role.User -> "You" ChatMessage.Role.User -> "You"
ChatMessage.Role.AI -> "Affine AI" ChatMessage.Role.AI -> "Affine AI"
}, },
color = Color.White, color = AFFiNETheme.colors.textPrimary,
fontSize = 17.sp, fontSize = 17.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( when (message.role) {
text = message.content, ChatMessage.Role.User -> Text(
color = Color.White, text = message.content,
fontSize = 16.sp, color = Color.White,
) fontSize = 16.sp,
)
ChatMessage.Role.AI -> Markdown(markdown = message.content)
}
} }
} }

View File

@@ -12,24 +12,15 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.InsertPhoto
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -38,7 +29,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@@ -52,7 +42,10 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.affine.pro.theme.AffineTheme import app.affine.pro.R
import app.affine.pro.components.AFFiNEIconButton
import app.affine.pro.theme.AFFiNETheme
import app.affine.pro.theme.ThemeMode
enum class InputSelector { enum class InputSelector {
NONE, NONE,
@@ -63,7 +56,7 @@ enum class InputSelector {
@Preview @Preview
@Composable @Composable
fun UserInputPreview() { fun UserInputPreview() {
AffineTheme(isDarkTheme = true) { AFFiNETheme(ThemeMode.Dark) {
UserInput(onMessageSent = {}) UserInput(onMessageSent = {})
} }
} }
@@ -71,8 +64,9 @@ fun UserInputPreview() {
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun UserInput( fun UserInput(
onMessageSent: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMessageSent: (String) -> Unit,
sendMessageEnabled: Boolean = true,
resetScroll: () -> Unit = {}, resetScroll: () -> Unit = {},
) { ) {
var currentInputSelector by rememberSaveable { mutableStateOf(InputSelector.NONE) } var currentInputSelector by rememberSaveable { mutableStateOf(InputSelector.NONE) }
@@ -98,8 +92,7 @@ fun UserInput(
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 2.dp, shadowElevation = 2.dp,
shape = RoundedCornerShape(16.dp, 16.dp, 0.dp, 0.dp), shape = RoundedCornerShape(16.dp, 16.dp, 0.dp, 0.dp),
color = MaterialTheme.colorScheme.surfaceContainer, color = AFFiNETheme.colors.backgroundOverlayPanel,
contentColor = MaterialTheme.colorScheme.onSurface,
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
UserInputText( UserInputText(
@@ -116,17 +109,15 @@ fun UserInput(
onMessageSent = { onMessageSent = {
onMessageSent(textState.text) onMessageSent(textState.text)
textState = TextFieldValue() textState = TextFieldValue()
resetScroll()
}, },
focusState = textFieldFocusState focusState = textFieldFocusState
) )
UserInputSelector( UserInputSelector(
onSelectorChange = { currentInputSelector = it }, onSelectorChange = { currentInputSelector = it },
sendMessageEnabled = textState.text.isNotBlank(), sendMessageEnabled = textState.text.isNotBlank() && sendMessageEnabled,
onMessageSent = { onMessageSent = {
onMessageSent(textState.text) onMessageSent(textState.text)
textState = TextFieldValue() textState = TextFieldValue()
resetScroll()
dismissKeyboard() dismissKeyboard()
}, },
) )
@@ -143,59 +134,30 @@ private fun UserInputSelector(
) { ) {
Row( Row(
modifier = modifier modifier = modifier
.height(44.dp)
.wrapContentHeight() .wrapContentHeight()
.padding(10.dp), .padding(10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton( AFFiNEIconButton(
modifier = Modifier.size(24.dp), R.drawable.ic_camera,
onClick = { onSelectorChange(InputSelector.CAMERA) }, onClick = { onSelectorChange(InputSelector.CAMERA) },
) {
Icon(
imageVector = Icons.Outlined.CameraAlt,
contentDescription = "Camera",
)
}
Spacer(modifier = Modifier.width(14.dp))
IconButton(
modifier = Modifier.size(24.dp),
onClick = { onSelectorChange(InputSelector.PICTURE) },
) {
Icon(
imageVector = Icons.Outlined.InsertPhoto,
contentDescription = "Picture",
)
}
Spacer(modifier = Modifier.width(14.dp))
VerticalDivider(modifier = Modifier.height(10.dp))
Checkbox(
modifier = Modifier.scale(0.8f),
checked = true,
enabled = false,
onCheckedChange = {},
) )
Text(text = "Embed this doc") Spacer(modifier = Modifier.width(14.dp))
AFFiNEIconButton(
R.drawable.ic_image,
onClick = { onSelectorChange(InputSelector.PICTURE) },
)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// Send button // Send button
IconButton( AFFiNEIconButton(
modifier = Modifier.size(24.dp), R.drawable.ic_send,
enabled = sendMessageEnabled, enabled = sendMessageEnabled,
onClick = onMessageSent, onClick = onMessageSent,
) { )
Icon(
imageVector = Icons.AutoMirrored.Default.Send,
contentDescription = "Send message",
)
}
} }
} }
@@ -221,7 +183,6 @@ private fun UserInputText(
) { ) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
UserInputTextField( UserInputTextField(
textFieldValue, textFieldValue,
onTextChanged, onTextChanged,
@@ -251,7 +212,7 @@ private fun BoxScope.UserInputTextField(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var lastFocusState by remember { mutableStateOf(false) } var lastFocusState by remember { mutableStateOf(false) }
val color = MaterialTheme.colorScheme.onSurfaceVariant val color = AFFiNETheme.colors.textPrimary
BasicTextField( BasicTextField(
value = textFieldValue, value = textFieldValue,
onValueChange = { onTextChanged(it) }, onValueChange = { onTextChanged(it) },
@@ -281,6 +242,7 @@ private fun BoxScope.UserInputTextField(
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
.padding(start = 10.dp, end = 10.dp, top = 10.dp), .padding(start = 10.dp, end = 10.dp, top = 10.dp),
text = "Feel free to input any text and see how AI responds. Give it a go!", text = "Feel free to input any text and see how AI responds. Give it a go!",
color = color
) )
} }
} }

View File

@@ -1,18 +1,15 @@
package app.affine.pro.components package app.affine.pro.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.automirrored.filled.ArrowBackIos
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -21,11 +18,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import app.affine.pro.theme.AffineTheme import androidx.compose.ui.unit.dp
import app.affine.pro.R
import app.affine.pro.theme.AFFiNETheme
import app.affine.pro.theme.ThemeMode
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AffineAppBar( fun AFFiNEAppBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
scrollBehavior: TopAppBarScrollBehavior? = null, scrollBehavior: TopAppBarScrollBehavior? = null,
onNavIconPressed: () -> Unit = { }, onNavIconPressed: () -> Unit = { },
@@ -37,30 +37,33 @@ fun AffineAppBar(
actions = actions, actions = actions,
title = title, title = title,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.centerAlignedTopAppBarColors().copy(
containerColor = AFFiNETheme.colors.backgroundPrimary,
titleContentColor = AFFiNETheme.colors.textPrimary,
),
navigationIcon = { navigationIcon = {
IconButton( AFFiNEIconButton(
R.drawable.ic_close,
modifier = Modifier.size(44.dp),
onClick = onNavIconPressed onClick = onNavIconPressed
) { )
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBackIos,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
) )
} }
@Composable @Composable
fun AffineDropMenu( fun AFFiNEDropMenu(
icon: @Composable () -> Unit, @DrawableRes resId: Int,
modifier: Modifier,
menuItems: @Composable ColumnScope.() -> Unit = {} menuItems: @Composable ColumnScope.() -> Unit = {}
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
Box { Box {
IconButton(onClick = { expanded = !expanded }) { AFFiNEIconButton(
icon() resId,
} modifier = modifier,
onClick = { expanded = !expanded },
)
DropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = { expanded = false } onDismissRequest = { expanded = false }
@@ -73,16 +76,12 @@ fun AffineDropMenu(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Preview @Preview
@Composable @Composable
fun AffineAppBarPreview() { fun AffineAppBarPreviewLight() {
AffineTheme { AFFiNETheme(mode = ThemeMode.Light) {
AffineAppBar( AFFiNEAppBar(
title = { Text("Preview!") }, title = { Text("Preview!") },
actions = { actions = {
AffineDropMenu( AFFiNEDropMenu(R.drawable.ic_more_horizontal, Modifier.size(44.dp))
icon = {
Icon(Icons.Default.MoreHoriz, contentDescription = "Actions")
},
)
}, },
) )
} }
@@ -92,15 +91,11 @@ fun AffineAppBarPreview() {
@Preview @Preview
@Composable @Composable
fun AffineAppBarPreviewDark() { fun AffineAppBarPreviewDark() {
AffineTheme(isDarkTheme = true) { AFFiNETheme(mode = ThemeMode.Dark) {
AffineAppBar( AFFiNEAppBar(
title = { Text("Preview!") }, title = { Text("Preview!") },
actions = { actions = {
AffineDropMenu( AFFiNEDropMenu(R.drawable.ic_more_horizontal, Modifier.size(44.dp))
icon = {
Icon(Icons.Default.MoreHoriz, contentDescription = "Actions")
},
)
}, },
) )
} }

View File

@@ -0,0 +1,47 @@
package app.affine.pro.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import app.affine.pro.theme.AFFiNETheme
@Composable
fun AFFiNEIcon(
@DrawableRes resId: Int,
modifier: Modifier = Modifier,
tint: Color = AFFiNETheme.colors.iconPrimary,
contentDescription: String? = null,
) {
Icon(
painter = painterResource(resId),
tint = tint,
contentDescription = contentDescription,
modifier = modifier.size(24.dp)
)
}
@Composable
fun AFFiNEIconButton(
@DrawableRes resId: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: IconButtonColors = IconButtonDefaults.iconButtonColors().copy(
contentColor = AFFiNETheme.colors.iconPrimary,
disabledContentColor = AFFiNETheme.colors.iconDisable,
),
interactionSource: MutableInteractionSource? = null,
) {
IconButton(onClick, modifier.size(24.dp), enabled, colors, interactionSource) {
AFFiNEIcon(resId)
}
}

View File

@@ -0,0 +1,223 @@
package app.affine.pro.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.affine.pro.R
import app.affine.pro.theme.AFFiNETheme
import app.affine.pro.theme.ThemeMode
import com.halilibo.richtext.commonmark.Markdown
import com.halilibo.richtext.ui.BasicRichText
import com.halilibo.richtext.ui.BlockQuoteGutter
import com.halilibo.richtext.ui.CodeBlockStyle
import com.halilibo.richtext.ui.HeadingStyle
import com.halilibo.richtext.ui.ListStyle
import com.halilibo.richtext.ui.OrderedMarkers
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.RichTextThemeProvider
import com.halilibo.richtext.ui.TableStyle
import com.halilibo.richtext.ui.UnorderedMarkers
import com.halilibo.richtext.ui.string.RichTextStringStyle
private val LocalMarkdownTextStyle = staticCompositionLocalOf {
TextStyle(
fontSize = 16.sp,
lineHeight = 21.sp,
fontWeight = FontWeight.Normal,
)
}
@Composable
fun Markdown(
modifier: Modifier = Modifier,
markdown: String,
) {
RichTextThemeProvider(
contentColorProvider = { AFFiNETheme.colors.textPrimary },
textStyleProvider = { LocalMarkdownTextStyle.current },
textStyleBackProvider = { textStyle, content ->
CompositionLocalProvider(LocalMarkdownTextStyle provides textStyle, content)
}
) {
val dividerColor = AFFiNETheme.colors.divider
BasicRichText(
modifier = modifier,
style = RichTextStyle(
headingStyle = headingStyle,
listStyle = listStyle,
blockQuoteGutter = BlockQuoteGutter.BarGutter(
startMargin = 0.sp,
barWidth = 5.sp,
endMargin = 8.sp,
color = { dividerColor },
),
codeBlockStyle = CodeBlockStyle(
textStyle = AFFiNETheme.typography.body.copy(
fontSize = 14.sp, fontFamily = FontFamily.Monospace
),
modifier = Modifier
.fillMaxWidth(1f)
.background(
color = AFFiNETheme.colors.backgroundCodeBlock,
shape = RoundedCornerShape(8.dp),
),
padding = 16.sp,
wordWrap = true,
),
tableStyle = TableStyle(borderColor = dividerColor, borderStrokeWidth = 2.dp.value),
stringStyle = RichTextStringStyle(
linkStyle = TextLinkStyles(
SpanStyle(color = AFFiNETheme.colors.textEmphasis),
SpanStyle(color = AFFiNETheme.colors.textEmphasis),
SpanStyle(color = AFFiNETheme.colors.textEmphasis),
SpanStyle(color = AFFiNETheme.colors.textEmphasis),
),
codeStyle = SpanStyle(
fontSize = 13.sp,
baselineShift = BaselineShift(0.08f),
background = AFFiNETheme.colors.backgroundCodeBlock,
)
)
),
) {
Markdown(markdown)
}
}
}
private val headingStyle: HeadingStyle = { level, textStyle ->
when (level) {
0 -> TextStyle(
fontSize = 28.sp, lineHeight = 34.sp, fontWeight = FontWeight.SemiBold
)
1 -> TextStyle(
fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.SemiBold
)
2 -> TextStyle(
fontSize = 20.sp, lineHeight = 25.sp, fontWeight = FontWeight.SemiBold
)
3 -> TextStyle(
fontSize = 17.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold
)
4 -> TextStyle(
fontSize = 17.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold
)
else -> textStyle.copy(fontWeight = FontWeight.SemiBold)
}
}
private val listStyle = ListStyle(orderedMarkers = {
OrderedMarkers { _, index ->
Text(
"${index + 1}.",
modifier = Modifier
.width(24.dp)
.padding(start = 4.dp),
style = AFFiNETheme.typography.body,
color = AFFiNETheme.colors.textEmphasis,
)
}
}, unorderedMarkers = {
val markers = listOf(
R.drawable.ic_bulleted_list_01,
R.drawable.ic_bulleted_list_02,
R.drawable.ic_bulleted_list_03,
R.drawable.ic_bulleted_list_04,
)
UnorderedMarkers { level ->
AFFiNEIcon(markers[level % markers.size], tint = AFFiNETheme.colors.textEmphasis)
}
})
@Preview
@Composable
fun MarkdownPreview() {
AFFiNETheme(mode = ThemeMode.Dark) {
Markdown(
markdown = """
当然可以,大熊!下面是一个包含**所有常用 Markdown 格式**的示例内容,您可以直接复制到 AFFiNE 或其他支持 Markdown 的编辑器中体验效果:
---
# 这是一级标题
## 这是二级标题
### 这是三级标题
---
**加粗文本***斜体文本*~~删除线文本~~`行内代码`
---
> 这是一个引用块,可以用来高亮重要信息。
---
- 无序列表项 1
- 无序列表项 2
- 嵌套子项
- 嵌套子项
- 嵌套子项
1. 有序列表项 1
1. 有序列表项 1-1
2. 有序列表项 1-2
2. 有序列表项 2
---
| 姓名 | 年龄 | 爱好 |
| ------ | ---- | -------- |
| 大熊 | 28 | 阅读 |
| 阿芬 | ∞ | 帮助你 |
---
```kotlin
// 这是一个 Kotlin 代码块
fun main() {
println(\"Hello, Markdown!\")
}
```
---
![示例图片](https://affine.pro/_next/static/media/logo.1e7b6b7e.svg)
---
[这是一个链接,点我访问 AFFiNE 官网](https://affine.pro)
---
大熊,如果还想体验更多格式或者有特殊内容需求,随时告诉阿芬!
""".trimIndent()
)
}
}

View File

@@ -8,7 +8,7 @@ import com.getcapacitor.annotation.CapacitorPlugin
import timber.log.Timber import timber.log.Timber
@CapacitorPlugin(name = "AffineTheme") @CapacitorPlugin(name = "AffineTheme")
class AffineThemePlugin : Plugin() { class AFFiNEThemePlugin : Plugin() {
interface Callback { interface Callback {
fun onThemeChanged(darkMode: Boolean) fun onThemeChanged(darkMode: Boolean)

View File

@@ -1,7 +1,6 @@
package app.affine.pro.plugin package app.affine.pro.plugin
import android.annotation.SuppressLint import android.annotation.SuppressLint
import app.affine.pro.CapacitorConfig
import app.affine.pro.service.CookieStore import app.affine.pro.service.CookieStore
import app.affine.pro.service.OkHttp import app.affine.pro.service.OkHttp
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
@@ -46,7 +45,6 @@ class AuthPlugin : Plugin() {
val endpoint = call.getStringEnsure("endpoint") val endpoint = call.getStringEnsure("endpoint")
val request = Request.Builder() val request = Request.Builder()
.url("$endpoint/api/auth/sign-out") .url("$endpoint/api/auth/sign-out")
.header("x-affine-version", CapacitorConfig.getAffineVersion())
.get() .get()
.build() .build()
OkHttp.client.newCall(request).executeAsync().use { response -> OkHttp.client.newCall(request).executeAsync().use { response ->
@@ -88,7 +86,6 @@ class AuthPlugin : Plugin() {
val requestBuilder = Request.Builder() val requestBuilder = Request.Builder()
.url("$endpoint/api/auth/sign-in") .url("$endpoint/api/auth/sign-in")
.header("x-affine-version", CapacitorConfig.getAffineVersion())
.post(body) .post(body)
if (verifyToken != null) { if (verifyToken != null) {
requestBuilder.addHeader("x-captcha-token", verifyToken) requestBuilder.addHeader("x-captcha-token", verifyToken)
@@ -114,7 +111,6 @@ class AuthPlugin : Plugin() {
Request.Builder() Request.Builder()
.url("$endpoint/api/oauth/callback") .url("$endpoint/api/oauth/callback")
.header("x-affine-version", CapacitorConfig.getAffineVersion())
.post(body) .post(body)
.build() .build()
} }
@@ -134,7 +130,6 @@ class AuthPlugin : Plugin() {
Request.Builder() Request.Builder()
.url("$endpoint/api/auth/magic-link") .url("$endpoint/api/auth/magic-link")
.header("x-affine-version", CapacitorConfig.getAffineVersion())
.post(body) .post(body)
.build() .build()
} }

View File

@@ -5,9 +5,11 @@ import app.affine.pro.utils.getCurrentServerBaseUrl
import com.affine.pro.graphql.CreateCopilotMessageMutation import com.affine.pro.graphql.CreateCopilotMessageMutation
import com.affine.pro.graphql.CreateCopilotSessionMutation import com.affine.pro.graphql.CreateCopilotSessionMutation
import com.affine.pro.graphql.GetCopilotHistoriesQuery import com.affine.pro.graphql.GetCopilotHistoriesQuery
import com.affine.pro.graphql.GetCopilotHistoryIdsQuery
import com.affine.pro.graphql.GetCopilotSessionsQuery import com.affine.pro.graphql.GetCopilotSessionsQuery
import com.affine.pro.graphql.type.CreateChatMessageInput import com.affine.pro.graphql.type.CreateChatMessageInput
import com.affine.pro.graphql.type.CreateChatSessionInput import com.affine.pro.graphql.type.CreateChatSessionInput
import com.affine.pro.graphql.type.QueryChatSessionsInput
import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.Mutation import com.apollographql.apollo.api.Mutation
import com.apollographql.apollo.api.Optional import com.apollographql.apollo.api.Optional
@@ -26,10 +28,13 @@ class GraphQLService @Inject constructor() {
suspend fun getCopilotSession(workspaceId: String, docId: String) = query( suspend fun getCopilotSession(workspaceId: String, docId: String) = query(
GetCopilotSessionsQuery( GetCopilotSessionsQuery(
workspaceId = workspaceId, workspaceId = workspaceId,
docId = Optional.present(docId) docId = Optional.present(docId),
options = Optional.present(QueryChatSessionsInput(action = Optional.present(false)))
) )
).mapCatching { data -> ).mapCatching { data ->
data.currentUser?.copilot?.sessions?.firstOrNull()?.id ?: error(ERROR_NULL_SESSION_ID) data.currentUser?.copilot?.sessions?.find {
it.parentSessionId == null
}?.id ?: error(ERROR_NULL_SESSION_ID)
} }
suspend fun createCopilotSession( suspend fun createCopilotSession(
@@ -63,6 +68,21 @@ class GraphQLService @Inject constructor() {
}?.messages ?: emptyList() }?.messages ?: emptyList()
} }
suspend fun getCopilotHistoryIds(
workspaceId: String,
docId: String,
sessionId: String,
) = query(
GetCopilotHistoryIdsQuery(
workspaceId = workspaceId,
docId = Optional.present(docId),
)
).mapCatching { data ->
data.currentUser?.copilot?.histories?.firstOrNull { history ->
history.sessionId == sessionId
}?.messages ?: emptyList()
}
suspend fun createCopilotMessage( suspend fun createCopilotMessage(
sessionId: String, sessionId: String,
message: String, message: String,

View File

@@ -1,6 +1,7 @@
package app.affine.pro.service package app.affine.pro.service
import app.affine.pro.AffineApp import app.affine.pro.AFFiNEApp
import app.affine.pro.CapacitorConfig
import app.affine.pro.utils.dataStore import app.affine.pro.utils.dataStore
import app.affine.pro.utils.set import app.affine.pro.utils.set
import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.crashlytics.ktx.crashlytics
@@ -32,6 +33,14 @@ object OkHttp {
CookieStore.saveCookies(url.host, cookies) CookieStore.saveCookies(url.host, cookies)
} }
}) })
.addInterceptor {
it.proceed(
it.request()
.newBuilder()
.addHeader("x-affine-version", CapacitorConfig.getAffineVersion())
.build()
)
}
.addInterceptor(HttpLoggingInterceptor { msg -> .addInterceptor(HttpLoggingInterceptor { msg ->
Timber.d(msg) Timber.d(msg)
}.apply { }.apply {
@@ -52,11 +61,11 @@ object CookieStore {
_cookies[host] = cookies _cookies[host] = cookies
MainScope().launch(Dispatchers.IO) { MainScope().launch(Dispatchers.IO) {
cookies.find { it.name == AFFINE_SESSION }?.let { cookies.find { it.name == AFFINE_SESSION }?.let {
AffineApp.context().dataStore.set(host + AFFINE_SESSION, it.toString()) AFFiNEApp.context().dataStore.set(host + AFFINE_SESSION, it.toString())
} }
cookies.find { it.name == AFFINE_USER_ID }?.let { cookies.find { it.name == AFFINE_USER_ID }?.let {
Timber.d("Update user id [${it.value}]") Timber.d("Update user id [${it.value}]")
AffineApp.context().dataStore.set(host + AFFINE_USER_ID, it.toString()) AFFiNEApp.context().dataStore.set(host + AFFINE_USER_ID, it.toString())
Firebase.crashlytics.setUserId(it.value) Firebase.crashlytics.setUserId(it.value)
} }
} }

View File

@@ -1,16 +0,0 @@
package app.affine.pro.theme
import androidx.compose.ui.graphics.Color
object Dark {
val BackgroundPrimary = Color(0XFF000000)
val InverseBackgroundPrimary = Color(0XFFFFFFFF)
val IconPrimary = Color(0XFFF3F3F3)
val Surface = Color(0XFF2A2A2A)
val TextPrimary = Color(0XFFE6E6E6)
}
object Light {
val BackgroundPrimary = Color(0XFFFFFFFF)
val InverseBackgroundPrimary = Color(0XFF000000)
}

View File

@@ -0,0 +1,344 @@
package app.affine.pro.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
@Immutable
data class AFFiNEColorScheme(
val textPrimary: Color,
val textSecondary: Color,
val textDisable: Color,
val textEmphasis: Color,
val backgroundPrimary: Color,
val backgroundSecondary: Color,
val backgroundOverlayPanel: Color,
val backgroundTertiary: Color,
val backgroundCodeBlock: Color,
val backgroundModal: Color,
val backgroundSuccess: Color,
val backgroundError: Color,
val backgroundWarning: Color,
val backgroundProgressing: Color,
val iconPrimary: Color,
val iconSecondary: Color,
val iconTertiary: Color,
val iconDisable: Color,
val iconActivated: Color,
val divider: Color,
)
val affineLightScheme = AFFiNEColorScheme(
textPrimary = AFFiNEColorTokens.Grey900,
textSecondary = AFFiNEColorTokens.Grey600,
textDisable = AFFiNEColorTokens.Grey400,
textEmphasis = AFFiNEColorTokens.AFFiNE600,
backgroundPrimary = AFFiNEColorTokens.BaseWhite,
backgroundSecondary = AFFiNEColorTokens.Grey100,
backgroundOverlayPanel = AFFiNEColorTokens.Grey50,
backgroundTertiary = AFFiNEColorTokens.Grey300,
backgroundCodeBlock = AFFiNEColorTokens.Grey50,
backgroundModal = AFFiNEColorTokens.TransparentBlack700,
backgroundSuccess = AFFiNEColorTokens.Emerald50,
backgroundError = AFFiNEColorTokens.Rose50,
backgroundWarning = AFFiNEColorTokens.Orange25,
backgroundProgressing = AFFiNEColorTokens.Indigo50,
iconPrimary = AFFiNEColorTokens.Grey600,
iconSecondary = AFFiNEColorTokens.Grey400,
iconTertiary = AFFiNEColorTokens.Grey300,
iconDisable = AFFiNEColorTokens.Grey400,
iconActivated = AFFiNEColorTokens.AFFiNE600,
divider = AFFiNEColorTokens.TransparentGrey400,
)
val affineDarkScheme = AFFiNEColorScheme(
textPrimary = AFFiNEColorTokens.Grey200,
textSecondary = AFFiNEColorTokens.Grey500,
textDisable = AFFiNEColorTokens.Grey700,
textEmphasis = AFFiNEColorTokens.AFFiNE500,
backgroundPrimary = AFFiNEColorTokens.Grey950,
backgroundSecondary = AFFiNEColorTokens.Grey900,
backgroundOverlayPanel = AFFiNEColorTokens.Grey900,
backgroundTertiary = AFFiNEColorTokens.Grey700,
backgroundCodeBlock = AFFiNEColorTokens.Grey900,
backgroundModal = AFFiNEColorTokens.TransparentBlack400,
backgroundSuccess = AFFiNEColorTokens.Emerald950,
backgroundError = AFFiNEColorTokens.Rose950,
backgroundWarning = AFFiNEColorTokens.Orange950,
backgroundProgressing = AFFiNEColorTokens.Indigo950,
iconPrimary = AFFiNEColorTokens.Grey100,
iconSecondary = AFFiNEColorTokens.Grey300,
iconTertiary = AFFiNEColorTokens.Grey700,
iconDisable = AFFiNEColorTokens.Grey800,
iconActivated = AFFiNEColorTokens.AFFiNE600,
divider = AFFiNEColorTokens.TransparentGrey300,
)
val LocalAFFiNEColors = staticCompositionLocalOf { affineLightScheme }
object AFFiNEColorTokens {
val BaseWhite = Color(0XFFFFFFFF)
val BaseBlack = Color(0XFF000000)
val Grey25 = Color(0XFFF9F9F9)
val Grey50 = Color(0XFFF5F5F5)
val Grey100 = Color(0XFFF3F3F3)
val Grey200 = Color(0XFFE6E6E6)
val Grey300 = Color(0XFFCDCDCD)
val Grey400 = Color(0XFFB3B3B3)
val Grey500 = Color(0XFF929292)
val Grey600 = Color(0XFF7A7A7A)
val Grey700 = Color(0XFF565656)
val Grey800 = Color(0XFF414141)
val Grey900 = Color(0XFF252525)
val Grey950 = Color(0XFF141414)
val Red25 = Color(0XFFFFF4F5)
val Red50 = Color(0XFFFCE5E6)
val Red100 = Color(0XFFFFD1D1)
val Red200 = Color(0XFFFBB7B7)
val Red300 = Color(0XFFFF9A9A)
val Red400 = Color(0XFFFC7979)
val Red500 = Color(0XFFF45252)
val Red600 = Color(0XFFED3F3F)
val Red700 = Color(0XFFC83030)
val Red800 = Color(0XFF9F2D2D)
val Red900 = Color(0XFF761717)
val Red950 = Color(0XFF460606)
val Orange25 = Color(0XFFFFF7EE)
val Orange50 = Color(0XFFFFEBD5)
val Orange100 = Color(0XFFFFDDB5)
val Orange200 = Color(0XFFFFD3AB)
val Orange300 = Color(0XFFFFC58F)
val Orange400 = Color(0XFFFFB978)
val Orange500 = Color(0XFFFFAD63)
val Orange600 = Color(0XFFFF8C39)
val Orange700 = Color(0XFFF37C26)
val Orange800 = Color(0XFFC96317)
val Orange900 = Color(0XFFA65113)
val Orange950 = Color(0XFF843A06)
val Amber25 = Color(0XFFFDFCF4)
val Amber50 = Color(0XFFFFFBEB)
val Amber100 = Color(0XFFFEF3C7)
val Amber200 = Color(0XFFFCE68A)
val Amber300 = Color(0XFFFCD34D)
val Amber400 = Color(0XFFFABF24)
val Amber500 = Color(0XFFF59E0A)
val Amber600 = Color(0XFFD97705)
val Amber700 = Color(0XFFB55309)
val Amber800 = Color(0XFF92400F)
val Amber900 = Color(0XFF78350F)
val Amber950 = Color(0XFF461A02)
val Yellow25 = Color(0XFFFFFAEC)
val Yellow50 = Color(0XFFFBF3D8)
val Yellow100 = Color(0XFFFFEFB6)
val Yellow200 = Color(0XFFFFE898)
val Yellow300 = Color(0XFFFEE483)
val Yellow400 = Color(0XFFFFDE6B)
val Yellow500 = Color(0XFFFEDC61)
val Yellow600 = Color(0XFFFFD337)
val Yellow700 = Color(0XFFE0AA00)
val Yellow800 = Color(0XFFAC7400)
val Yellow900 = Color(0XFF704200)
val Yellow950 = Color(0XFF321A04)
val Lime25 = Color(0XFFFBFEF1)
val Lime50 = Color(0XFFF9FEE7)
val Lime100 = Color(0XFFF0FCCB)
val Lime200 = Color(0XFFE0F99D)
val Lime300 = Color(0XFFC9F263)
val Lime400 = Color(0XFFB2E634)
val Lime500 = Color(0XFF92CC16)
val Lime600 = Color(0XFF72A10E)
val Lime700 = Color(0XFF577C0E)
val Lime800 = Color(0XFF466213)
val Lime900 = Color(0XFF3B5314)
val Lime950 = Color(0XFF1A2E06)
val Green25 = Color(0XFFEFFFEC)
val Green50 = Color(0XFFDCFDD7)
val Green100 = Color(0XFFC9F8C1)
val Green200 = Color(0XFFB8F0AF)
val Green300 = Color(0XFFA8E79C)
val Green400 = Color(0XFF9CDA91)
val Green500 = Color(0XFF91CE87)
val Green600 = Color(0XFF7CC270)
val Green700 = Color(0XFF64BA56)
val Green800 = Color(0XFF439036)
val Green900 = Color(0XFF225C18)
val Green950 = Color(0XFF10340A)
val Emerald25 = Color(0XFFEDFDF5)
val Emerald50 = Color(0XFFF1FDF4)
val Emerald100 = Color(0XFFDDFAE3)
val Emerald200 = Color(0XFFBEF3CC)
val Emerald300 = Color(0XFF9DE6B4)
val Emerald400 = Color(0XFF7FD295)
val Emerald500 = Color(0XFF69B87D)
val Emerald600 = Color(0XFF539566)
val Emerald700 = Color(0XFF447756)
val Emerald800 = Color(0XFF375F46)
val Emerald900 = Color(0XFF2E4F3C)
val Emerald950 = Color(0XFF1D3027)
val Teal25 = Color(0XFFEFFFFD)
val Teal50 = Color(0XFFE1FDF9)
val Teal100 = Color(0XFFD7FBF6)
val Teal200 = Color(0XFFC6F8F2)
val Teal300 = Color(0XFFB5F5EE)
val Teal400 = Color(0XFFA7F1E9)
val Teal500 = Color(0XFF8AE7DD)
val Teal600 = Color(0XFF7AE1D5)
val Teal700 = Color(0XFF5CC7BA)
val Teal800 = Color(0XFF448E86)
val Teal900 = Color(0XFF1C6B63)
val Teal950 = Color(0XFF0E4841)
val Blue25 = Color(0XFFF0F9FF)
val Blue50 = Color(0XFFE6F5FF)
val Blue100 = Color(0XFFDAF0FF)
val Blue200 = Color(0XFFCEECFF)
val Blue300 = Color(0XFFBAE4FF)
val Blue400 = Color(0XFFAADEFF)
val Blue500 = Color(0XFFA0D9FF)
val Blue600 = Color(0XFF84CFFF)
val Blue700 = Color(0XFF53B2EF)
val Blue800 = Color(0XFF2F94D5)
val Blue900 = Color(0XFF1C70A5)
val Blue950 = Color(0XFF004B7B)
val AFFiNE25 = Color(0XFFE2F4FF)
val AFFiNE50 = Color(0XFFCAE9FF)
val AFFiNE100 = Color(0XFF8FD1FF)
val AFFiNE200 = Color(0XFF79C8FF)
val AFFiNE300 = Color(0XFF5EBCFF)
val AFFiNE400 = Color(0XFF49B1FA)
val AFFiNE500 = Color(0XFF29A2FA)
val AFFiNE600 = Color(0XFF1D96EB)
val AFFiNE700 = Color(0XFF158ADE)
val AFFiNE800 = Color(0XFF035F9F)
val AFFiNE900 = Color(0XFF003C67)
val AFFiNE950 = Color(0XFF002742)
val Indigo25 = Color(0XFFF5F7FF)
val Indigo50 = Color(0XFFEEF2FF)
val Indigo100 = Color(0XFFE0E7FF)
val Indigo200 = Color(0XFFC7D2FE)
val Indigo300 = Color(0XFFA5B4FC)
val Indigo400 = Color(0XFF818CF8)
val Indigo500 = Color(0XFF6366F1)
val Indigo600 = Color(0XFF4F46E5)
val Indigo700 = Color(0XFF4338CA)
val Indigo800 = Color(0XFF3730A3)
val Indigo900 = Color(0XFF312E81)
val Indigo950 = Color(0XFF1E1B4B)
val Violet25 = Color(0XFFF9F7FF)
val Violet50 = Color(0XFFF5F3FF)
val Violet100 = Color(0XFFEDE9FE)
val Violet200 = Color(0XFFDDD6FE)
val Violet300 = Color(0XFFC5B5FD)
val Violet400 = Color(0XFFA78BFA)
val Violet500 = Color(0XFF8B5CF6)
val Violet600 = Color(0XFF7C3AED)
val Violet700 = Color(0XFF6E28D9)
val Violet800 = Color(0XFF5A21B6)
val Violet900 = Color(0XFF4B1E95)
val Violet950 = Color(0XFF2E1065)
val Purple25 = Color(0XFFF0ECFF)
val Purple50 = Color(0XFFDED6FF)
val Purple100 = Color(0XFFCBBEFF)
val Purple200 = Color(0XFFB5A5EF)
val Purple300 = Color(0XFFA593F3)
val Purple400 = Color(0XFF9681EF)
val Purple500 = Color(0XFF846CE9)
val Purple600 = Color(0XFF6E52DF)
val Purple700 = Color(0XFF5739D1)
val Purple800 = Color(0XFF4A2EBC)
val Purple900 = Color(0XFF321994)
val Purple950 = Color(0XFF25136D)
val Fuchsia25 = Color(0XFFFEFAFF)
val Fuchsia50 = Color(0XFFFCF4FF)
val Fuchsia100 = Color(0XFFFAE8FF)
val Fuchsia200 = Color(0XFFF5D0FE)
val Fuchsia300 = Color(0XFFF0AAFC)
val Fuchsia400 = Color(0XFFE879F9)
val Fuchsia500 = Color(0XFFD946EF)
val Fuchsia600 = Color(0XFFC025D3)
val Fuchsia700 = Color(0XFFA21EAF)
val Fuchsia800 = Color(0XFF86198F)
val Fuchsia900 = Color(0XFF701A75)
val Fuchsia950 = Color(0XFF4A044E)
val Magenta25 = Color(0XFFFFECF6)
val Magenta50 = Color(0XFFFFDAED)
val Magenta100 = Color(0XFFFFC0E0)
val Magenta200 = Color(0XFFFCA2D0)
val Magenta300 = Color(0XFFF58EC3)
val Magenta400 = Color(0XFFF37FBA)
val Magenta500 = Color(0XFFE96CAA)
val Magenta600 = Color(0XFFE660A4)
val Magenta700 = Color(0XFFCC4187)
val Magenta800 = Color(0XFFBA2B72)
val Magenta900 = Color(0XFFA91E65)
val Magenta950 = Color(0XFF89124F)
val Rose25 = Color(0XFFFFFAFA)
val Rose50 = Color(0XFFFFF1F1)
val Rose100 = Color(0XFFFEE4E5)
val Rose200 = Color(0XFFFECDD1)
val Rose300 = Color(0XFFFDA2AB)
val Rose400 = Color(0XFFFB7181)
val Rose500 = Color(0XFFF43F48)
val Rose600 = Color(0XFFE11E41)
val Rose700 = Color(0XFFBE1237)
val Rose800 = Color(0XFF9F1235)
val Rose900 = Color(0XFF881331)
val Rose950 = Color(0XFF4D051A)
val TransparentWhite25 = Color(0XFFFFFFFF).copy(alpha = 0.03f)
val TransparentWhite50 = Color(0XFFFFFFFF).copy(alpha = 0.05f)
val TransparentWhite100 = Color(0XFFFFFFFF).copy(alpha = 0.09f)
val TransparentWhite200 = Color(0XFFFFFFFF).copy(alpha = 0.13f)
val TransparentWhite300 = Color(0XFFFFFFFF).copy(alpha = 0.17f)
val TransparentWhite400 = Color(0XFFFFFFFF).copy(alpha = 0.23f)
val TransparentWhite500 = Color(0XFFFFFFFF).copy(alpha = 0.56f)
val TransparentWhite600 = Color(0XFFFFFFFF).copy(alpha = 0.67f)
val TransparentWhite700 = Color(0XFFFFFFFF).copy(alpha = 0.72f)
val TransparentWhite800 = Color(0XFFFFFFFF).copy(alpha = 0.82f)
val TransparentWhite900 = Color(0XFFFFFFFF).copy(alpha = 0.90f)
val TransparentWhite950 = Color(0XFFFFFFFF).copy(alpha = 0.98f)
val TransparentBlack25 = Color(0XFF000000).copy(alpha = 0.03f)
val TransparentBlack50 = Color(0XFF000000).copy(alpha = 0.05f)
val TransparentBlack100 = Color(0XFF000000).copy(alpha = 0.07f)
val TransparentBlack200 = Color(0XFF000000).copy(alpha = 0.10f)
val TransparentBlack300 = Color(0XFF000000).copy(alpha = 0.17f)
val TransparentBlack400 = Color(0XFF000000).copy(alpha = 0.22f)
val TransparentBlack500 = Color(0XFF000000).copy(alpha = 0.52f)
val TransparentBlack600 = Color(0XFF000000).copy(alpha = 0.65f)
val TransparentBlack700 = Color(0XFF000000).copy(alpha = 0.70f)
val TransparentBlack800 = Color(0XFF000000).copy(alpha = 0.80f)
val TransparentBlack900 = Color(0XFF000000).copy(alpha = 0.90f)
val TransparentBlack950 = Color(0XFF000000).copy(alpha = 0.95f)
val TransparentGrey25 = Color(0XFF929292).copy(alpha = 0.03f)
val TransparentGrey50 = Color(0XFF929292).copy(alpha = 0.05f)
val TransparentGrey100 = Color(0XFF929292).copy(alpha = 0.07f)
val TransparentGrey200 = Color(0XFF929292).copy(alpha = 0.10f)
val TransparentGrey300 = Color(0XFF929292).copy(alpha = 0.17f)
val TransparentGrey400 = Color(0XFF929292).copy(alpha = 0.22f)
val TransparentGrey500 = Color(0XFF929292).copy(alpha = 0.52f)
val TransparentGrey600 = Color(0XFF929292).copy(alpha = 0.65f)
val TransparentGrey700 = Color(0XFF929292).copy(alpha = 0.70f)
val TransparentGrey800 = Color(0XFF929292).copy(alpha = 0.80f)
val TransparentGrey900 = Color(0XFF929292).copy(alpha = 0.90f)
val TransparentGrey950 = Color(0XFF929292).copy(alpha = 0.95f)
}

View File

@@ -1,31 +1,49 @@
package app.affine.pro.theme package app.affine.pro.theme
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
val AffineDarkColorScheme = darkColorScheme( object AFFiNETheme {
background = Dark.BackgroundPrimary, val colors: AFFiNEColorScheme
onSurface = Dark.TextPrimary, @ReadOnlyComposable
onSurfaceVariant = Dark.IconPrimary, @Composable
surfaceContainer = Dark.Surface, get() = LocalAFFiNEColors.current
surface = Dark.BackgroundPrimary,
inverseSurface = Dark.InverseBackgroundPrimary, val typography: AFFiNETypography
) @ReadOnlyComposable
val AffineLightColorScheme = lightColorScheme() @Composable
get() = LocalAFFiNETypography.current
}
@SuppressLint("NewApi")
@Composable @Composable
fun AffineTheme( fun AFFiNETheme(
isDarkTheme: Boolean = isSystemInDarkTheme(), mode: ThemeMode = ThemeMode.System,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
MaterialTheme( val colors = when (mode) {
colorScheme = if (isDarkTheme) AffineDarkColorScheme else AffineLightColorScheme, ThemeMode.Light -> affineLightScheme
typography = AffineTypography, ThemeMode.Dark -> affineDarkScheme
content = content ThemeMode.System -> if (isSystemInDarkTheme()) affineDarkScheme else affineLightScheme
) }
CompositionLocalProvider(LocalAFFiNEColors provides colors) {
MaterialTheme {
content()
}
}
}
enum class ThemeMode(name: String) {
Light("light"),
Dark("dark"),
System("system");
fun of(name: String) = when (name) {
"light" -> Light
"dark" -> Dark
else -> System
}
} }

View File

@@ -1,5 +1,58 @@
package app.affine.pro.theme package app.affine.pro.theme
import androidx.compose.material3.Typography import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val AffineTypography = Typography() @Immutable
data class AFFiNETypography(
val body: TextStyle,
val h1: TextStyle,
val h2: TextStyle,
val h3: TextStyle,
val h4: TextStyle,
val h5: TextStyle,
val h6: TextStyle,
)
val affineTypography = AFFiNETypography(
body = TextStyle(
fontSize = 16.sp,
lineHeight = 21.sp,
fontWeight = FontWeight.Normal,
),
h1 = TextStyle(
fontSize = 28.sp,
lineHeight = 34.sp,
fontWeight = FontWeight.SemiBold,
),
h2 = TextStyle(
fontSize = 22.sp,
lineHeight = 28.sp,
fontWeight = FontWeight.SemiBold,
),
h3 = TextStyle(
fontSize = 20.sp,
lineHeight = 25.sp,
fontWeight = FontWeight.SemiBold,
),
h4 = TextStyle(
fontSize = 17.sp,
lineHeight = 22.sp,
fontWeight = FontWeight.SemiBold,
),
h5 = TextStyle(
fontSize = 17.sp,
lineHeight = 22.sp,
fontWeight = FontWeight.SemiBold,
),
h6 = TextStyle(
fontSize = 16.sp,
lineHeight = 21.sp,
fontWeight = FontWeight.SemiBold,
),
)
val LocalAFFiNETypography = staticCompositionLocalOf { affineTypography }

View File

@@ -4,9 +4,9 @@
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path
android:pathData="M12.174,5.491C12.133,5.13 11.828,4.858 11.465,4.857C11.102,4.857 10.796,5.129 10.754,5.49C10.484,7.835 9.783,9.484 8.651,10.616C7.52,11.747 5.871,12.448 3.525,12.719C3.164,12.761 2.892,13.066 2.893,13.429C2.893,13.792 3.166,14.097 3.526,14.138C5.833,14.4 7.518,15.101 8.676,16.238C9.83,17.371 10.546,19.02 10.752,21.349C10.785,21.718 11.094,22 11.465,22C11.835,22 12.144,21.716 12.176,21.347C12.374,19.056 13.089,17.373 14.249,16.213C15.408,15.054 17.092,14.339 19.383,14.14C19.752,14.108 20.035,13.8 20.035,13.429C20.036,13.059 19.753,12.75 19.384,12.717C17.055,12.51 15.406,11.794 14.273,10.64C13.136,9.482 12.435,7.798 12.174,5.491Z" android:pathData="M11.281,5.491C11.24,5.13 10.935,4.858 10.572,4.857C10.209,4.857 9.903,5.129 9.862,5.49C9.591,7.835 8.89,9.484 7.759,10.616C6.627,11.747 4.978,12.448 2.632,12.719C2.272,12.761 2,13.066 2,13.429C2,13.792 2.273,14.097 2.634,14.138C4.941,14.4 6.625,15.101 7.783,16.238C8.937,17.371 9.653,19.02 9.86,21.349C9.893,21.718 10.202,22 10.572,22C10.943,22 11.251,21.716 11.283,21.347C11.481,19.056 12.197,17.373 13.356,16.213C14.516,15.054 16.199,14.339 18.49,14.14C18.859,14.108 19.142,13.8 19.143,13.429C19.143,13.059 18.861,12.75 18.492,12.717C16.163,12.51 14.514,11.794 13.381,10.64C12.243,9.482 11.542,7.798 11.281,5.491Z"
android:fillColor="#1E96EB"/> android:fillColor="#7A7A7A"/>
<path <path
android:pathData="M19.835,2.247C19.819,2.106 19.701,2 19.559,2C19.418,2 19.299,2.106 19.283,2.246C19.178,3.158 18.905,3.8 18.465,4.239C18.025,4.679 17.384,4.952 16.472,5.057C16.332,5.074 16.226,5.192 16.226,5.334C16.226,5.475 16.332,5.593 16.472,5.609C17.369,5.711 18.025,5.984 18.475,6.426C18.924,6.866 19.202,7.508 19.283,8.413C19.295,8.557 19.416,8.667 19.56,8.667C19.704,8.667 19.824,8.556 19.836,8.413C19.913,7.522 20.191,6.867 20.642,6.416C21.093,5.965 21.748,5.687 22.639,5.61C22.782,5.598 22.892,5.478 22.893,5.334C22.893,5.19 22.783,5.069 22.639,5.057C21.734,4.976 21.092,4.698 20.652,4.249C20.209,3.799 19.937,3.144 19.835,2.247Z" android:pathData="M18.943,2.247C18.927,2.106 18.808,2 18.667,2C18.526,2 18.407,2.106 18.391,2.246C18.285,3.158 18.013,3.8 17.573,4.239C17.133,4.679 16.491,4.952 15.579,5.057C15.439,5.074 15.333,5.192 15.333,5.334C15.333,5.475 15.439,5.593 15.58,5.609C16.477,5.711 17.132,5.984 17.582,6.426C18.031,6.866 18.309,7.508 18.39,8.413C18.403,8.557 18.523,8.667 18.667,8.667C18.811,8.667 18.931,8.556 18.943,8.413C19.021,7.522 19.299,6.867 19.75,6.416C20.201,5.965 20.855,5.687 21.746,5.61C21.89,5.598 22,5.478 22,5.334C22,5.19 21.89,5.069 21.747,5.057C20.841,4.976 20.2,4.698 19.759,4.249C19.317,3.799 19.044,3.144 18.943,2.247Z"
android:fillColor="#1E96EB"/> android:fillColor="#7A7A7A"/>
</vector> </vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M17.58,8.97C17.873,9.263 17.873,9.737 17.58,10.03L12.58,15.03C12.288,15.323 11.813,15.323 11.52,15.03L6.52,10.03C6.227,9.737 6.227,9.263 6.52,8.97C6.813,8.677 7.287,8.677 7.58,8.97L12.05,13.439L16.52,8.97C16.813,8.677 17.288,8.677 17.58,8.97Z"
android:fillColor="#7A7A7A"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M17.507,10.997C17.202,11.277 16.728,11.257 16.448,10.952L12.75,6.925L12.75,19C12.75,19.414 12.414,19.75 12,19.75C11.586,19.75 11.25,19.414 11.25,19L11.25,6.925L7.552,10.952C7.272,11.257 6.798,11.277 6.493,10.997C6.188,10.717 6.167,10.242 6.448,9.937L11.448,4.493C11.59,4.338 11.79,4.25 12,4.25C12.21,4.25 12.41,4.338 12.552,4.493L17.552,9.937C17.833,10.242 17.812,10.717 17.507,10.997Z"
android:fillColor="#7A7A7A"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20.777,3.723C21.074,4.02 21.074,4.501 20.777,4.798L13.809,11.766C15.092,13.503 15.15,15.9 13.877,17.712L10.996,21.812C10.786,22.11 10.375,22.183 10.076,21.976C7.125,19.933 4.567,17.375 2.524,14.424C2.317,14.125 2.39,13.714 2.688,13.504L6.788,10.623C8.6,9.35 10.997,9.408 12.734,10.691L19.702,3.723C19.999,3.426 20.48,3.426 20.777,3.723ZM10.249,20.231L12.633,16.838C12.642,16.825 12.651,16.811 12.66,16.798C11.637,16.397 10.604,15.715 9.695,14.805C8.785,13.896 8.103,12.863 7.702,11.84C7.689,11.849 7.675,11.858 7.662,11.867L4.269,14.251C5.952,16.532 7.968,18.548 10.249,20.231ZM9.112,11.269C9.425,12.077 9.982,12.943 10.77,13.73C11.557,14.518 12.423,15.075 13.231,15.388C13.42,14.277 13.078,13.105 12.236,12.264C11.395,11.422 10.223,11.08 9.112,11.269Z"
android:fillColor="#7A7A7A"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M2.25,9C2.25,6.377 4.377,4.25 7,4.25H17C19.623,4.25 21.75,6.377 21.75,9V12.798C21.75,15.421 19.623,17.548 17,17.548H11.127L7.933,20.547C7.715,20.751 7.396,20.807 7.122,20.688C6.847,20.569 6.67,20.299 6.67,20V17.536C4.2,17.367 2.25,15.31 2.25,12.798V9ZM7,5.75C5.205,5.75 3.75,7.205 3.75,9V12.798C3.75,14.593 5.205,16.048 7,16.048H7.42C7.834,16.048 8.17,16.383 8.17,16.798V18.267L10.317,16.251C10.456,16.12 10.64,16.048 10.83,16.048H17C18.795,16.048 20.25,14.593 20.25,12.798V9C20.25,7.205 18.795,5.75 17,5.75H7Z"
android:fillColor="#7A7A7A"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M7,12m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0"
android:fillColor="#7A7A7A"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M7,14.25C8.243,14.25 9.25,13.243 9.25,12C9.25,10.757 8.243,9.75 7,9.75C5.757,9.75 4.75,10.757 4.75,12C4.75,13.243 5.757,14.25 7,14.25ZM7,15C8.657,15 10,13.657 10,12C10,10.343 8.657,9 7,9C5.343,9 4,10.343 4,12C4,13.657 5.343,15 7,15Z"
android:fillColor="#7A7A7A"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6.408,9.245C6.735,8.918 7.265,8.918 7.592,9.245L9.755,11.408C10.082,11.735 10.082,12.265 9.755,12.592L7.592,14.755C7.265,15.082 6.735,15.082 6.408,14.755L4.245,12.592C3.918,12.265 3.918,11.735 4.245,11.408L6.408,9.245Z"
android:fillColor="#7A7A7A"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9.163,12L7,9.837L4.837,12L7,14.163L9.163,12ZM7.592,9.245C7.265,8.918 6.735,8.918 6.408,9.245L4.245,11.408C3.918,11.735 3.918,12.265 4.245,12.592L6.408,14.755C6.735,15.082 7.265,15.082 7.592,14.755L9.755,12.592C10.082,12.265 10.082,11.735 9.755,11.408L7.592,9.245Z"
android:fillColor="#7A7A7A"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M10.285,5.75C9.936,5.75 9.615,5.922 9.427,6.2L8.704,7.266C8.233,7.962 7.444,8.375 6.604,8.375H5.778C5.199,8.375 4.75,8.834 4.75,9.375V17.25C4.75,17.791 5.199,18.25 5.778,18.25H18.222C18.801,18.25 19.25,17.791 19.25,17.25V9.375C19.25,8.834 18.801,8.375 18.222,8.375H17.396C16.556,8.375 15.767,7.962 15.296,7.266L14.573,6.2C14.385,5.922 14.064,5.75 13.715,5.75H10.285ZM8.185,5.359C8.656,4.663 9.445,4.25 10.285,4.25H13.715C14.555,4.25 15.344,4.663 15.815,5.359L16.538,6.425C16.726,6.703 17.047,6.875 17.396,6.875H18.222C19.607,6.875 20.75,7.983 20.75,9.375V17.25C20.75,18.642 19.607,19.75 18.222,19.75H5.778C4.393,19.75 3.25,18.642 3.25,17.25V9.375C3.25,7.983 4.393,6.875 5.778,6.875H6.604C6.953,6.875 7.274,6.703 7.462,6.425L8.185,5.359ZM12,11C10.93,11 10.083,11.851 10.083,12.875C10.083,13.899 10.93,14.75 12,14.75C13.07,14.75 13.917,13.899 13.917,12.875C13.917,11.851 13.07,11 12,11ZM8.583,12.875C8.583,11 10.124,9.5 12,9.5C13.876,9.5 15.417,11 15.417,12.875C15.417,14.75 13.876,16.25 12,16.25C10.124,16.25 8.583,14.75 8.583,12.875Z"
android:fillColor="#7A7A7A"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M5.47,5.47C5.763,5.177 6.237,5.177 6.53,5.47L12,10.939L17.47,5.47C17.763,5.177 18.237,5.177 18.53,5.47C18.823,5.763 18.823,6.237 18.53,6.53L13.061,12L18.53,17.47C18.823,17.763 18.823,18.237 18.53,18.53C18.237,18.823 17.763,18.823 17.47,18.53L12,13.061L6.53,18.53C6.237,18.823 5.763,18.823 5.47,18.53C5.177,18.237 5.177,17.763 5.47,17.47L10.939,12L5.47,6.53C5.177,6.237 5.177,5.763 5.47,5.47Z"
android:fillColor="#7A7A7A"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,3.25C4.481,3.25 3.25,4.481 3.25,6V16V18C3.25,19.519 4.481,20.75 6,20.75H18C19.519,20.75 20.75,19.519 20.75,18V14V6C20.75,4.481 19.519,3.25 18,3.25H6ZM4.75,18V16.311L9.116,11.944C9.604,11.456 10.396,11.456 10.884,11.944L13.47,14.53L15.47,16.53C15.763,16.823 16.237,16.823 16.53,16.53C16.823,16.237 16.823,15.763 16.53,15.47L15.061,14L16.116,12.944C16.604,12.456 17.396,12.456 17.884,12.944L19.25,14.311V18C19.25,18.69 18.69,19.25 18,19.25H6C5.31,19.25 4.75,18.69 4.75,18ZM11.944,10.884L14,12.939L15.056,11.884C16.129,10.81 17.871,10.81 18.944,11.884L19.25,12.189V6C19.25,5.31 18.69,4.75 18,4.75H6C5.31,4.75 4.75,5.31 4.75,6V14.189L8.055,10.884C9.129,9.81 10.871,9.81 11.944,10.884ZM14,9C14.552,9 15,8.552 15,8C15,7.448 14.552,7 14,7C13.448,7 13,7.448 13,8C13,8.552 13.448,9 14,9Z"
android:fillColor="#7A7A7A"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4.5,12C4.5,12.956 5.275,13.731 6.231,13.731C7.187,13.731 7.962,12.956 7.962,12C7.962,11.044 7.187,10.269 6.231,10.269C5.275,10.269 4.5,11.044 4.5,12ZM12,13.731C11.044,13.731 10.269,12.956 10.269,12C10.269,11.044 11.044,10.269 12,10.269C12.956,10.269 13.731,11.044 13.731,12C13.731,12.956 12.956,13.731 12,13.731ZM17.769,13.731C16.813,13.731 16.038,12.956 16.038,12C16.038,11.044 16.813,10.269 17.769,10.269C18.725,10.269 19.5,11.044 19.5,12C19.5,12.956 18.725,13.731 17.769,13.731Z"
android:fillColor="#7A7A7A"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20.088,4.143C20.088,4.143 21.892,3.44 21.741,5.148C21.691,5.852 21.24,8.314 20.889,10.976L19.687,18.864C19.687,18.864 19.587,20.02 18.685,20.221C17.783,20.422 16.43,19.518 16.179,19.317C15.979,19.166 12.422,16.905 11.169,15.8C10.818,15.498 10.417,14.895 11.219,14.192L16.48,9.167C17.081,8.565 17.682,7.158 15.177,8.866L8.162,13.639C8.162,13.639 7.36,14.142 5.857,13.689L2.6,12.684C2.6,12.684 1.397,11.931 3.452,11.177C8.463,8.816 14.626,6.405 20.087,4.143H20.088Z"
android:fillColor="#7A7A7A"/>
</vector>

View File

@@ -2,4 +2,5 @@
<resources> <resources>
<color name="layer.background.primary">#FFFFFF</color> <color name="layer.background.primary">#FFFFFF</color>
<color name="layer.background.primary.dark">#141414</color> <color name="layer.background.primary.dark">#141414</color>
<color name="affine_primary">#1E96EB</color>
</resources> </resources>

View File

@@ -34,6 +34,7 @@ ksp = "2.1.20-2.0.1"
minSdk = "22" minSdk = "22"
mozilla-rust-android = "0.9.6" mozilla-rust-android = "0.9.6"
okhttp-bom = "5.0.0-alpha.14" okhttp-bom = "5.0.0-alpha.14"
richtext = "1.0.0-alpha02"
# @keep # @keep
targetSdk = "35" targetSdk = "35"
timber = "5.0.1" timber = "5.0.1"
@@ -90,6 +91,8 @@ okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp
okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp-coroutines" } okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp-coroutines" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor" } okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor" }
okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse" } okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse" }
richtext-commonmark = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "richtext" }
richtext-ui = { module = "com.halilibo.compose-richtext:richtext-ui", version.ref = "richtext" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
[plugins] [plugins]