feat: add Android app scaffold with clean architecture

Multi-module Kotlin/Compose project with WebSocket chat client:
- app: MainActivity, DI setup with Hilt
- core/data: Room DB, WebSocket client, repository impl
- core/domain: models, repository interface, use cases
- core/ui: Material3 theme
- feature/chat: ChatScreen with message bubbles, image preview, connection banner

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-18 19:17:29 +09:00
parent f6d32bf951
commit 3eb99d9d1a
55 changed files with 1704 additions and 0 deletions

7
.gitignore vendored
View file

@ -42,5 +42,12 @@ tasks/
.vscode/ .vscode/
.idea/ .idea/
# Android
*.apk
*.aab
*.keystore
*.jks
local.properties
# Added by goreleaser init: # Added by goreleaser init:
dist/ dist/

13
android/.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/build
/feature/*/build
/core/*/build

View file

@ -0,0 +1,64 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.serialization)
}
android {
namespace = "io.picoclaw.android"
compileSdk = 35
defaultConfig {
applicationId = "io.picoclaw.android"
minSdk = 28
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(project(":feature:chat"))
implementation(project(":core:domain"))
implementation(project(":core:data"))
implementation(project(":core:ui"))
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.material3)
implementation(libs.activity.compose)
implementation(libs.core.ktx)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.koin.android)
implementation(libs.koin.compose)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.websockets)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
implementation(libs.coroutines.android)
debugImplementation(libs.compose.ui.tooling)
}

6
android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,6 @@
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** { *** Companion; }
-keepclasseswithmembers class io.picoclaw.android.** {
kotlinx.serialization.KSerializer serializer(...);
}

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name=".PicoClawApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.PicoClaw">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.PicoClaw">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,20 @@
package io.picoclaw.android
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import io.picoclaw.android.core.ui.theme.PicoClawTheme
import io.picoclaw.android.feature.chat.screen.ChatScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PicoClawTheme {
ChatScreen()
}
}
}
}

View file

@ -0,0 +1,16 @@
package io.picoclaw.android
import android.app.Application
import io.picoclaw.android.di.appModule
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
class PicoClawApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@PicoClawApp)
modules(appModule)
}
}
}

View file

@ -0,0 +1,65 @@
package io.picoclaw.android.di
import androidx.room.Room
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.websocket.WebSockets
import io.picoclaw.android.core.data.local.AppDatabase
import io.picoclaw.android.core.data.remote.WebSocketClient
import io.picoclaw.android.core.data.repository.ChatRepositoryImpl
import io.picoclaw.android.core.domain.repository.ChatRepository
import io.picoclaw.android.core.domain.usecase.LoadMoreMessagesUseCase
import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
import io.picoclaw.android.feature.chat.ChatViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
import java.util.concurrent.TimeUnit
val appModule = module {
// CoroutineScope
single { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
// Room
single {
Room.databaseBuilder(
androidContext(),
AppDatabase::class.java,
"picoclaw.db"
).build()
}
single { get<AppDatabase>().messageDao() }
// Ktor HttpClient
single {
HttpClient(OkHttp) {
install(WebSockets)
engine {
preconfigured = OkHttpClient.Builder()
.pingInterval(30, TimeUnit.SECONDS)
.build()
}
}
}
// WebSocketClient
single { WebSocketClient(get()) }
// Repository
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get()) }
// UseCases
factory { SendMessageUseCase(get()) }
factory { ObserveMessagesUseCase(get()) }
factory { ObserveConnectionUseCase(get()) }
factory { LoadMoreMessagesUseCase(get()) }
// ViewModel
viewModel { ChatViewModel(get(), get(), get(), get(), get()) }
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@color/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#2196F3</color>
<color name="ic_launcher_foreground">#FFFFFF</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">PicoClaw</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.PicoClaw" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>

7
android/build.gradle.kts Normal file
View file

@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.serialization) apply false
alias(libs.plugins.ksp) apply false
}

View file

@ -0,0 +1,35 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.serialization)
alias(libs.plugins.ksp)
}
android {
namespace = "io.picoclaw.android.core.data"
compileSdk = 35
defaultConfig {
minSdk = 28
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(project(":core:domain"))
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.json)
implementation(libs.serialization.json)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.coroutines.android)
}

View file

@ -0,0 +1,14 @@
package io.picoclaw.android.core.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import io.picoclaw.android.core.data.local.converter.Converters
import io.picoclaw.android.core.data.local.dao.MessageDao
import io.picoclaw.android.core.data.local.entity.MessageEntity
@Database(entities = [MessageEntity::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
}

View file

@ -0,0 +1,17 @@
package io.picoclaw.android.core.data.local.converter
import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class Converters {
@TypeConverter
fun fromStringList(value: List<String>?): String? {
return value?.let { Json.encodeToString(it) }
}
@TypeConverter
fun toStringList(value: String?): List<String>? {
return value?.let { Json.decodeFromString(it) }
}
}

View file

@ -0,0 +1,24 @@
package io.picoclaw.android.core.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import io.picoclaw.android.core.data.local.entity.MessageEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface MessageDao {
@Query("SELECT * FROM messages ORDER BY timestamp DESC LIMIT :limit")
fun getRecentMessages(limit: Int): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE timestamp < :beforeTimestamp ORDER BY timestamp DESC LIMIT :limit")
suspend fun getMessagesBefore(beforeTimestamp: Long, limit: Int): List<MessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(message: MessageEntity)
@Update
suspend fun update(message: MessageEntity)
}

View file

@ -0,0 +1,14 @@
package io.picoclaw.android.core.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey val id: String,
val content: String,
val sender: String,
val imageBase64List: String?,
val timestamp: Long,
val status: String
)

View file

@ -0,0 +1,67 @@
package io.picoclaw.android.core.data.mapper
import io.picoclaw.android.core.data.local.entity.MessageEntity
import io.picoclaw.android.core.data.remote.dto.WsIncoming
import io.picoclaw.android.core.data.remote.dto.WsOutgoing
import io.picoclaw.android.core.domain.model.ChatMessage
import io.picoclaw.android.core.domain.model.ImageAttachment
import io.picoclaw.android.core.domain.model.MessageSender
import io.picoclaw.android.core.domain.model.MessageStatus
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
object MessageMapper {
fun toDomain(entity: MessageEntity): ChatMessage {
val images = entity.imageBase64List?.let {
try {
Json.decodeFromString<List<String>>(it)
} catch (_: Exception) {
emptyList()
}
} ?: emptyList()
return ChatMessage(
id = entity.id,
content = entity.content,
sender = MessageSender.valueOf(entity.sender),
images = images,
timestamp = entity.timestamp,
status = MessageStatus.valueOf(entity.status)
)
}
fun toEntity(dto: WsOutgoing): MessageEntity {
return MessageEntity(
id = UUID.randomUUID().toString(),
content = dto.content,
sender = MessageSender.AGENT.name,
imageBase64List = null,
timestamp = System.currentTimeMillis(),
status = MessageStatus.RECEIVED.name
)
}
fun toEntity(text: String, images: List<ImageAttachment>, status: MessageStatus): MessageEntity {
val imageJson = if (images.isNotEmpty()) {
Json.encodeToString(images.map { it.base64 })
} else null
return MessageEntity(
id = UUID.randomUUID().toString(),
content = text,
sender = MessageSender.USER.name,
imageBase64List = imageJson,
timestamp = System.currentTimeMillis(),
status = status.name
)
}
fun toWsIncoming(text: String, images: List<ImageAttachment>): WsIncoming {
return WsIncoming(
content = text,
images = if (images.isNotEmpty()) images.map { it.base64 } else null
)
}
}

View file

@ -0,0 +1,94 @@
package io.picoclaw.android.core.data.remote
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.readText
import io.picoclaw.android.core.data.remote.dto.WsIncoming
import io.picoclaw.android.core.data.remote.dto.WsOutgoing
import io.picoclaw.android.core.domain.model.ConnectionState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class WebSocketClient(private val client: HttpClient) {
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _incomingMessages = MutableSharedFlow<WsOutgoing>(extraBufferCapacity = 64)
val incomingMessages: SharedFlow<WsOutgoing> = _incomingMessages.asSharedFlow()
private var session: WebSocketSession? = null
private var connectJob: Job? = null
private val json = Json { ignoreUnknownKeys = true }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
var wsUrl: String = "ws://127.0.0.1:18793/ws"
fun connect() {
if (connectJob?.isActive == true) return
connectJob = scope.launch {
var retryDelay = INITIAL_DELAY
while (isActive) {
try {
_connectionState.value = ConnectionState.CONNECTING
client.webSocket(wsUrl) {
session = this
_connectionState.value = ConnectionState.CONNECTED
retryDelay = INITIAL_DELAY
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
try {
val msg = json.decodeFromString<WsOutgoing>(text)
_incomingMessages.emit(msg)
} catch (_: Exception) {
}
}
}
}
} catch (_: Exception) {
}
session = null
_connectionState.value = ConnectionState.RECONNECTING
delay(retryDelay)
retryDelay = (retryDelay * 2).coerceAtMost(MAX_DELAY)
}
}
}
fun disconnect() {
connectJob?.cancel()
connectJob = null
session = null
_connectionState.value = ConnectionState.DISCONNECTED
}
suspend fun send(dto: WsIncoming): Boolean {
return try {
session?.send(Frame.Text(json.encodeToString(dto)))
true
} catch (_: Exception) {
false
}
}
companion object {
private const val INITIAL_DELAY = 1000L
private const val MAX_DELAY = 30000L
}
}

View file

@ -0,0 +1,11 @@
package io.picoclaw.android.core.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class WsIncoming(
val content: String,
@SerialName("sender_id") val senderId: String? = null,
val images: List<String>? = null
)

View file

@ -0,0 +1,6 @@
package io.picoclaw.android.core.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class WsOutgoing(val content: String)

View file

@ -0,0 +1,72 @@
package io.picoclaw.android.core.data.repository
import io.picoclaw.android.core.data.local.dao.MessageDao
import io.picoclaw.android.core.data.mapper.MessageMapper
import io.picoclaw.android.core.data.remote.WebSocketClient
import io.picoclaw.android.core.domain.model.ChatMessage
import io.picoclaw.android.core.domain.model.ConnectionState
import io.picoclaw.android.core.domain.model.ImageAttachment
import io.picoclaw.android.core.domain.model.MessageStatus
import io.picoclaw.android.core.domain.repository.ChatRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ChatRepositoryImpl(
private val webSocketClient: WebSocketClient,
private val messageDao: MessageDao,
private val scope: CoroutineScope
) : ChatRepository {
private val _displayLimit = MutableStateFlow(INITIAL_LOAD_COUNT)
@Suppress("OPT_IN_USAGE")
override val messages: StateFlow<List<ChatMessage>> =
_displayLimit.flatMapLatest { limit ->
messageDao.getRecentMessages(limit)
}.map { entities ->
entities.map { MessageMapper.toDomain(it) }.reversed()
}.stateIn(scope, SharingStarted.Lazily, emptyList())
override val connectionState: StateFlow<ConnectionState> = webSocketClient.connectionState
init {
scope.launch {
webSocketClient.incomingMessages.collect { dto ->
val entity = MessageMapper.toEntity(dto)
messageDao.insert(entity)
}
}
}
override suspend fun sendMessage(text: String, images: List<ImageAttachment>) {
val entity = MessageMapper.toEntity(text, images, MessageStatus.SENDING)
messageDao.insert(entity)
val wsDto = MessageMapper.toWsIncoming(text, images)
val success = webSocketClient.send(wsDto)
messageDao.update(entity.copy(status = if (success) MessageStatus.SENT.name else MessageStatus.FAILED.name))
}
override fun loadMore() {
_displayLimit.update { it + PAGE_SIZE }
}
override fun connect() {
webSocketClient.connect()
}
override fun disconnect() {
webSocketClient.disconnect()
}
companion object {
const val INITIAL_LOAD_COUNT = 50
const val PAGE_SIZE = 30
}
}

View file

@ -0,0 +1,21 @@
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "io.picoclaw.android.core.domain"
compileSdk = 35
defaultConfig {
minSdk = 28
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(libs.coroutines.android)
}

View file

@ -0,0 +1,14 @@
package io.picoclaw.android.core.domain.model
data class ChatMessage(
val id: String,
val content: String,
val sender: MessageSender,
val images: List<String> = emptyList(),
val timestamp: Long,
val status: MessageStatus
)
enum class MessageSender { USER, AGENT }
enum class MessageStatus { SENDING, SENT, FAILED, RECEIVED }

View file

@ -0,0 +1,5 @@
package io.picoclaw.android.core.domain.model
enum class ConnectionState {
DISCONNECTED, CONNECTING, CONNECTED, RECONNECTING
}

View file

@ -0,0 +1,7 @@
package io.picoclaw.android.core.domain.model
data class ImageAttachment(
val uri: String? = null,
val base64: String,
val mimeType: String = "image/png"
)

View file

@ -0,0 +1,15 @@
package io.picoclaw.android.core.domain.repository
import io.picoclaw.android.core.domain.model.ChatMessage
import io.picoclaw.android.core.domain.model.ConnectionState
import io.picoclaw.android.core.domain.model.ImageAttachment
import kotlinx.coroutines.flow.StateFlow
interface ChatRepository {
val messages: StateFlow<List<ChatMessage>>
val connectionState: StateFlow<ConnectionState>
suspend fun sendMessage(text: String, images: List<ImageAttachment> = emptyList())
fun loadMore()
fun connect()
fun disconnect()
}

View file

@ -0,0 +1,9 @@
package io.picoclaw.android.core.domain.usecase
import io.picoclaw.android.core.domain.repository.ChatRepository
class LoadMoreMessagesUseCase(private val repository: ChatRepository) {
operator fun invoke() {
repository.loadMore()
}
}

View file

@ -0,0 +1,9 @@
package io.picoclaw.android.core.domain.usecase
import io.picoclaw.android.core.domain.model.ConnectionState
import io.picoclaw.android.core.domain.repository.ChatRepository
import kotlinx.coroutines.flow.StateFlow
class ObserveConnectionUseCase(private val repository: ChatRepository) {
operator fun invoke(): StateFlow<ConnectionState> = repository.connectionState
}

View file

@ -0,0 +1,9 @@
package io.picoclaw.android.core.domain.usecase
import io.picoclaw.android.core.domain.model.ChatMessage
import io.picoclaw.android.core.domain.repository.ChatRepository
import kotlinx.coroutines.flow.StateFlow
class ObserveMessagesUseCase(private val repository: ChatRepository) {
operator fun invoke(): StateFlow<List<ChatMessage>> = repository.messages
}

View file

@ -0,0 +1,10 @@
package io.picoclaw.android.core.domain.usecase
import io.picoclaw.android.core.domain.model.ImageAttachment
import io.picoclaw.android.core.domain.repository.ChatRepository
class SendMessageUseCase(private val repository: ChatRepository) {
suspend operator fun invoke(text: String, images: List<ImageAttachment> = emptyList()) {
repository.sendMessage(text, images)
}
}

View file

@ -0,0 +1,31 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "io.picoclaw.android.core.ui"
compileSdk = 35
defaultConfig {
minSdk = 28
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.material3)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
}

View file

@ -0,0 +1,17 @@
package io.picoclaw.android.core.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val UserBubble = Color(0xFF2196F3)
val AgentBubble = Color(0xFF424242)
val ConnectedGreen = Color(0xFF4CAF50)
val ReconnectingYellow = Color(0xFFFFC107)
val DisconnectedRed = Color(0xFFF44336)

View file

@ -0,0 +1,45 @@
package io.picoclaw.android.core.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
)
@Composable
fun PicoClawTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View file

@ -0,0 +1,17 @@
package io.picoclaw.android.core.ui.theme
import androidx.compose.material3.Typography
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.sp
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
)

View file

@ -0,0 +1,43 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "io.picoclaw.android.feature.chat"
compileSdk = 35
defaultConfig {
minSdk = 28
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:ui"))
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.icons.extended)
implementation(libs.activity.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.koin.android)
implementation(libs.koin.compose)
implementation(libs.coroutines.android)
debugImplementation(libs.compose.ui.tooling)
}

View file

@ -0,0 +1,13 @@
package io.picoclaw.android.feature.chat
import io.picoclaw.android.core.domain.model.ImageAttachment
sealed interface ChatEvent {
data class OnInputChanged(val text: String) : ChatEvent
data object OnSendClick : ChatEvent
data class OnImageAdded(val image: ImageAttachment) : ChatEvent
data class OnImageRemoved(val index: Int) : ChatEvent
data object OnLoadMore : ChatEvent
data class OnError(val message: String) : ChatEvent
data object OnErrorDismissed : ChatEvent
}

View file

@ -0,0 +1,15 @@
package io.picoclaw.android.feature.chat
import io.picoclaw.android.core.domain.model.ChatMessage
import io.picoclaw.android.core.domain.model.ConnectionState
import io.picoclaw.android.core.domain.model.ImageAttachment
data class ChatUiState(
val messages: List<ChatMessage> = emptyList(),
val connectionState: ConnectionState = ConnectionState.DISCONNECTED,
val inputText: String = "",
val pendingImages: List<ImageAttachment> = emptyList(),
val isLoadingMore: Boolean = false,
val canLoadMore: Boolean = true,
val error: String? = null
)

View file

@ -0,0 +1,85 @@
package io.picoclaw.android.feature.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.picoclaw.android.core.domain.repository.ChatRepository
import io.picoclaw.android.core.domain.usecase.LoadMoreMessagesUseCase
import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ChatViewModel(
private val sendMessage: SendMessageUseCase,
private val observeMessages: ObserveMessagesUseCase,
private val observeConnection: ObserveConnectionUseCase,
private val loadMoreMessages: LoadMoreMessagesUseCase,
private val repository: ChatRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
init {
repository.connect()
viewModelScope.launch {
observeMessages().collect { messages ->
_uiState.update { it.copy(messages = messages) }
}
}
viewModelScope.launch {
observeConnection().collect { state ->
_uiState.update { it.copy(connectionState = state) }
}
}
}
fun onEvent(event: ChatEvent) {
when (event) {
is ChatEvent.OnInputChanged -> {
_uiState.update { it.copy(inputText = event.text) }
}
is ChatEvent.OnSendClick -> {
val state = _uiState.value
val text = state.inputText.trim()
if (text.isEmpty() && state.pendingImages.isEmpty()) return
_uiState.update { it.copy(inputText = "", pendingImages = emptyList()) }
viewModelScope.launch {
try {
sendMessage(text, state.pendingImages)
} catch (e: Exception) {
_uiState.update { it.copy(error = e.message) }
}
}
}
is ChatEvent.OnImageAdded -> {
_uiState.update { it.copy(pendingImages = it.pendingImages + event.image) }
}
is ChatEvent.OnImageRemoved -> {
_uiState.update {
it.copy(pendingImages = it.pendingImages.filterIndexed { i, _ -> i != event.index })
}
}
is ChatEvent.OnLoadMore -> {
loadMoreMessages()
}
is ChatEvent.OnError -> {
_uiState.update { it.copy(error = event.message) }
}
is ChatEvent.OnErrorDismissed -> {
_uiState.update { it.copy(error = null) }
}
}
}
override fun onCleared() {
super.onCleared()
repository.disconnect()
}
}

View file

@ -0,0 +1,45 @@
package io.picoclaw.android.feature.chat.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.picoclaw.android.core.domain.model.ConnectionState
import io.picoclaw.android.core.ui.theme.DisconnectedRed
import io.picoclaw.android.core.ui.theme.ReconnectingYellow
@Composable
fun ConnectionBanner(
connectionState: ConnectionState,
modifier: Modifier = Modifier
) {
AnimatedVisibility(visible = connectionState != ConnectionState.CONNECTED) {
val (color, text) = when (connectionState) {
ConnectionState.CONNECTING -> ReconnectingYellow to "Connecting..."
ConnectionState.RECONNECTING -> ReconnectingYellow to "Reconnecting..."
ConnectionState.DISCONNECTED -> DisconnectedRed to "Disconnected"
ConnectionState.CONNECTED -> Color.Transparent to ""
}
Box(
modifier = modifier
.fillMaxWidth()
.background(color)
.padding(vertical = 4.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = Color.White,
fontSize = 12.sp
)
}
}
}

View file

@ -0,0 +1,74 @@
package io.picoclaw.android.feature.chat.component
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import io.picoclaw.android.core.domain.model.ImageAttachment
@Composable
fun ImagePreviewRow(
images: List<ImageAttachment>,
onRemove: (Int) -> Unit,
modifier: Modifier = Modifier
) {
if (images.isEmpty()) return
LazyRow(
modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(images) { index, attachment ->
Box {
val bitmap = remember(attachment.base64) {
try {
val bytes = Base64.decode(attachment.base64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
} catch (_: Exception) {
null
}
}
bitmap?.let {
Image(
bitmap = it,
contentDescription = null,
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
}
IconButton(
onClick = { onRemove(index) },
modifier = Modifier
.size(20.dp)
.align(Alignment.TopEnd)
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(14.dp)
)
}
}
}
}
}

View file

@ -0,0 +1,85 @@
package io.picoclaw.android.feature.chat.component
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import io.picoclaw.android.core.domain.model.ChatMessage
import io.picoclaw.android.core.domain.model.MessageSender
import io.picoclaw.android.core.ui.theme.AgentBubble
import io.picoclaw.android.core.ui.theme.UserBubble
@Composable
fun MessageBubble(
message: ChatMessage,
modifier: Modifier = Modifier
) {
val isUser = message.sender == MessageSender.USER
val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
val bubbleColor = if (isUser) UserBubble else AgentBubble
val shape = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (isUser) 16.dp else 4.dp,
bottomEnd = if (isUser) 4.dp else 16.dp
)
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = alignment
) {
Surface(
shape = shape,
color = bubbleColor,
modifier = Modifier.widthIn(max = 300.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
message.images.forEach { base64 ->
val bitmap = remember(base64) {
try {
val bytes = Base64.decode(base64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
} catch (_: Exception) {
null
}
}
bitmap?.let {
Image(
bitmap = it,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
contentScale = ContentScale.FillWidth
)
}
}
if (message.content.isNotEmpty()) {
Text(
text = message.content,
color = Color.White,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
}

View file

@ -0,0 +1,61 @@
package io.picoclaw.android.feature.chat.component
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun MessageInput(
text: String,
onTextChanged: (String) -> Unit,
onSendClick: () -> Unit,
onCameraClick: () -> Unit,
onGalleryClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterEnd
) {
IconButton(onClick = onCameraClick) {
Icon(Icons.Default.CameraAlt, contentDescription = "Camera")
}
IconButton(onClick = onGalleryClick) {
Icon(Icons.Default.Image, contentDescription = "Gallery")
}
OutlinedTextField(
value = text,
onValueChange = onTextChanged,
modifier = Modifier.weight(1f),
placeholder = { Text("Message...") },
maxLines = 4,
shape = MaterialTheme.shapes.extraLarge
)
Spacer(modifier = Modifier.width(8.dp))
FilledIconButton(
onClick = onSendClick,
shape = CircleShape
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
}
}
}

View file

@ -0,0 +1,50 @@
package io.picoclaw.android.feature.chat.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.picoclaw.android.core.domain.model.ChatMessage
@Composable
fun MessageList(
messages: List<ChatMessage>,
listState: LazyListState,
isLoadingMore: Boolean,
modifier: Modifier = Modifier
) {
LazyColumn(
state = listState,
reverseLayout = true,
modifier = modifier,
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = messages.reversed(),
key = { it.id }
) { message ->
MessageBubble(message = message)
}
if (isLoadingMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
}
}

View file

@ -0,0 +1,79 @@
package io.picoclaw.android.feature.chat.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import io.picoclaw.android.feature.chat.ChatEvent
import io.picoclaw.android.feature.chat.ChatViewModel
import io.picoclaw.android.feature.chat.component.ConnectionBanner
import io.picoclaw.android.feature.chat.component.ImagePreviewRow
import io.picoclaw.android.feature.chat.component.MessageInput
import io.picoclaw.android.feature.chat.component.MessageList
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
viewModel: ChatViewModel = koinViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val listState = rememberLazyListState()
val shouldLoadMore by remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
lastVisibleItem != null &&
lastVisibleItem.index >= listState.layoutInfo.totalItemsCount - 5
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) viewModel.onEvent(ChatEvent.OnLoadMore)
}
Scaffold(
topBar = {
TopAppBar(title = { Text("PicoClaw") })
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
ConnectionBanner(connectionState = uiState.connectionState)
MessageList(
messages = uiState.messages,
listState = listState,
isLoadingMore = uiState.isLoadingMore,
modifier = Modifier.weight(1f)
)
ImagePreviewRow(
images = uiState.pendingImages,
onRemove = { viewModel.onEvent(ChatEvent.OnImageRemoved(it)) }
)
MessageInput(
text = uiState.inputText,
onTextChanged = { viewModel.onEvent(ChatEvent.OnInputChanged(it)) },
onSendClick = { viewModel.onEvent(ChatEvent.OnSendClick) },
onCameraClick = { /* TODO */ },
onGalleryClick = { /* TODO */ }
)
}
}
}

View file

@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
org.gradle.parallel=true
org.gradle.caching=true

View file

@ -0,0 +1,56 @@
[versions]
agp = "8.7.3"
kotlin = "2.1.0"
ksp = "2.1.0-1.0.29"
compose-bom = "2024.12.01"
ktor = "3.0.3"
koin = "4.0.2"
serialization = "1.7.3"
room = "2.6.1"
lifecycle = "2.8.7"
activity-compose = "1.9.3"
core-ktx = "1.15.0"
coroutines = "1.9.0"
[libraries]
# Compose
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# AndroidX
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
# Ktor
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" }
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
# Serialization
serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Koin
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
# Coroutines
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

158
android/gradlew vendored Normal file
View file

@ -0,0 +1,158 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced functionality shells and target
# temporary focusing, currentunixfunctionality, if present.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-hierarchical paths.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld -- "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NonStop* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
;;
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
;;
esac
fi
# Collect all arguments for the java command, stracks the current directory, preserve the arguments.
# shellcheck disable=SC2153
case $GRADLE_USER_HOME in
'') GRADLE_USER_HOME_OPTION="" ;;
*) GRADLE_USER_HOME_OPTION="-Dgradle.user.home=$GRADLE_USER_HOME" ;;
esac
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and are treated as combinator of argments
# * $APP_HOME is guaranteed to be a normal path
# * gradle-wrapper.jar should not contain special characters
# shellcheck disable=SC2086
exec "$JAVACMD" \
$DEFAULT_JVM_OPTS \
$JAVA_OPTS \
$GRADLE_OPTS \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
$GRADLE_USER_HOME_OPTION \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"

91
android/gradlew.bat vendored Normal file
View file

@ -0,0 +1,91 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %OS%==Windows_NT endlocal
:omega
exit /b %ERRORLEVEL%
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%

View file

@ -0,0 +1,20 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "PicoClaw"
include(":app")
include(":feature:chat")
include(":core:domain")
include(":core:data")
include(":core:ui")