feat: implement Ghost CMS microblogging Android app (Swoosh)

Full Kotlin/Jetpack Compose app for personal microblogging against a Ghost instance.

- Setup screen with Ghost URL + Admin API key (EncryptedSharedPreferences)
- Twitter/Mastodon-style feed with pull-to-refresh and infinite scroll
- Composer with image attach, link preview (OpenGraph), publish/draft/schedule
- Post detail view with edit and delete actions
- Ghost Admin API JWT auth (HS256, key splitting, 5-min expiry)
- Offline queue with Room DB and WorkManager for connectivity-aware uploads
- Material 3 theming with dynamic color support
- Settings screen for reconfiguring instance credentials

https://claude.ai/code/session_01CpMtDAEfMd14A8MQubMppS
This commit is contained in:
Claude 2026-03-18 22:43:53 +00:00
commit c76174ff5e
No known key found for this signature in database
38 changed files with 2710 additions and 0 deletions

11
.gitignore vendored Normal file
View file

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

111
app/build.gradle.kts Normal file
View file

@ -0,0 +1,111 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
}
android {
namespace = "com.swoosh.microblog"
compileSdk = 34
defaultConfig {
applicationId = "com.swoosh.microblog"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
}
}
dependencies {
// Compose BOM
val composeBom = platform("androidx.compose:compose-bom:2024.01.00")
implementation(composeBom)
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
// Compose
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.6")
// Retrofit + OkHttp
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Coil for image loading
implementation("io.coil-kt:coil-compose:2.5.0")
// Room
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// WorkManager
implementation("androidx.work:work-runtime-ktx:2.9.0")
// EncryptedSharedPreferences
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// JWT generation
implementation("io.jsonwebtoken:jjwt-api:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-gson:0.12.3")
// Jsoup for OpenGraph parsing
implementation("org.jsoup:jsoup:1.17.2")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

16
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,16 @@
# Keep JJWT classes
-keep class io.jsonwebtoken.** { *; }
-keepnames class io.jsonwebtoken.* { *; }
-keepnames interface io.jsonwebtoken.* { *; }
# Keep Gson serialization models
-keep class com.swoosh.microblog.data.model.** { *; }
-keep class com.swoosh.microblog.data.api.** { *; }
# Retrofit
-keepattributes Signature
-keepattributes *Annotation*
-keep class retrofit2.** { *; }
-keepclasseswithmembers class * {
@retrofit2.http.* <methods>;
}

View file

@ -0,0 +1,29 @@
<?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.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name=".SwooshApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Swoosh"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Swoosh">
<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,31 @@
package com.swoosh.microblog
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.navigation.compose.rememberNavController
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.ui.navigation.Routes
import com.swoosh.microblog.ui.navigation.SwooshNavGraph
import com.swoosh.microblog.ui.theme.SwooshTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val credentials = CredentialsManager(this)
val startDestination = if (credentials.isConfigured) Routes.FEED else Routes.SETUP
setContent {
SwooshTheme {
val navController = rememberNavController()
SwooshNavGraph(
navController = navController,
startDestination = startDestination
)
}
}
}
}

View file

@ -0,0 +1,12 @@
package com.swoosh.microblog
import android.app.Application
import com.swoosh.microblog.worker.PostUploadWorker
class SwooshApp : Application() {
override fun onCreate() {
super.onCreate()
// Schedule periodic connectivity check for queued posts
PostUploadWorker.enqueuePeriodicCheck(this)
}
}

View file

@ -0,0 +1,41 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class CredentialsManager(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
"swoosh_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
var ghostUrl: String?
get() = prefs.getString(KEY_GHOST_URL, null)
set(value) = prefs.edit().putString(KEY_GHOST_URL, value).apply()
var adminApiKey: String?
get() = prefs.getString(KEY_ADMIN_API_KEY, null)
set(value) = prefs.edit().putString(KEY_ADMIN_API_KEY, value).apply()
val isConfigured: Boolean
get() = !ghostUrl.isNullOrBlank() && !adminApiKey.isNullOrBlank()
fun clear() {
prefs.edit().clear().apply()
}
companion object {
private const val KEY_GHOST_URL = "ghost_url"
private const val KEY_ADMIN_API_KEY = "admin_api_key"
}
}

View file

@ -0,0 +1,66 @@
package com.swoosh.microblog.data.api
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object ApiClient {
@Volatile
private var retrofit: Retrofit? = null
@Volatile
private var currentBaseUrl: String? = null
fun getService(baseUrl: String, apiKeyProvider: () -> String?): GhostApiService {
val normalizedUrl = normalizeUrl(baseUrl)
if (retrofit == null || currentBaseUrl != normalizedUrl) {
synchronized(this) {
if (retrofit == null || currentBaseUrl != normalizedUrl) {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val client = OkHttpClient.Builder()
.addInterceptor(GhostAuthInterceptor(apiKeyProvider))
.addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
retrofit = Retrofit.Builder()
.baseUrl(normalizedUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
currentBaseUrl = normalizedUrl
}
}
}
return retrofit!!.create(GhostApiService::class.java)
}
fun reset() {
synchronized(this) {
retrofit = null
currentBaseUrl = null
}
}
private fun normalizeUrl(url: String): String {
var normalized = url.trim()
if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
normalized = "https://$normalized"
}
if (!normalized.endsWith("/")) {
normalized = "$normalized/"
}
return normalized
}
}

View file

@ -0,0 +1,54 @@
package com.swoosh.microblog.data.api
import com.swoosh.microblog.data.model.PostWrapper
import com.swoosh.microblog.data.model.PostsResponse
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Response
import retrofit2.http.*
interface GhostApiService {
@GET("ghost/api/admin/posts/")
suspend fun getPosts(
@Query("limit") limit: Int = 15,
@Query("page") page: Int = 1,
@Query("include") include: String = "authors",
@Query("formats") formats: String = "html,plaintext,mobiledoc",
@Query("order") order: String = "created_at desc"
): Response<PostsResponse>
@POST("ghost/api/admin/posts/")
@Headers("Content-Type: application/json")
suspend fun createPost(
@Body body: PostWrapper
): Response<PostsResponse>
@PUT("ghost/api/admin/posts/{id}/")
@Headers("Content-Type: application/json")
suspend fun updatePost(
@Path("id") id: String,
@Body body: PostWrapper
): Response<PostsResponse>
@DELETE("ghost/api/admin/posts/{id}/")
suspend fun deletePost(
@Path("id") id: String
): Response<Unit>
@Multipart
@POST("ghost/api/admin/images/upload/")
suspend fun uploadImage(
@Part file: MultipartBody.Part,
@Part("purpose") purpose: RequestBody
): Response<ImageUploadResponse>
}
data class ImageUploadResponse(
val images: List<UploadedImage>
)
data class UploadedImage(
val url: String,
val ref: String?
)

View file

@ -0,0 +1,22 @@
package com.swoosh.microblog.data.api
import okhttp3.Interceptor
import okhttp3.Response
class GhostAuthInterceptor(
private val apiKeyProvider: () -> String?
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val apiKey = apiKeyProvider()
val request = if (apiKey != null) {
val token = GhostJwtGenerator.generateToken(apiKey)
chain.request().newBuilder()
.addHeader("Authorization", "Ghost $token")
.build()
} else {
chain.request()
}
return chain.proceed(request)
}
}

View file

@ -0,0 +1,42 @@
package com.swoosh.microblog.data.api
import io.jsonwebtoken.Jwts
import java.util.Date
import javax.crypto.spec.SecretKeySpec
object GhostJwtGenerator {
/**
* Generate a Ghost Admin API JWT from the admin API key.
* The key format is: {key_id}:{secret_hex}
* We sign an HS256 JWT with 5-minute expiry.
*/
fun generateToken(adminApiKey: String): String {
val parts = adminApiKey.split(":")
require(parts.size == 2) { "Invalid API key format. Expected {id}:{secret}" }
val keyId = parts[0]
val secretHex = parts[1]
// Decode hex secret to bytes
val secretBytes = secretHex.chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
val now = Date()
val expiry = Date(now.time + 5 * 60 * 1000) // 5 minutes
val key = SecretKeySpec(secretBytes, "HmacSHA256")
return Jwts.builder()
.header()
.add("kid", keyId)
.and()
.issuedAt(now)
.expiration(expiry)
.audience().add("/admin/")
.and()
.signWith(key)
.compact()
}
}

View file

@ -0,0 +1,32 @@
package com.swoosh.microblog.data.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.swoosh.microblog.data.model.LocalPost
@Database(entities = [LocalPost::class], version = 1, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun localPostDao(): LocalPostDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"swoosh_database"
).build()
INSTANCE = instance
instance
}
}
}
}

View file

@ -0,0 +1,19 @@
package com.swoosh.microblog.data.db
import androidx.room.TypeConverter
import com.swoosh.microblog.data.model.PostStatus
import com.swoosh.microblog.data.model.QueueStatus
class Converters {
@TypeConverter
fun fromPostStatus(value: PostStatus): String = value.name
@TypeConverter
fun toPostStatus(value: String): PostStatus = PostStatus.valueOf(value)
@TypeConverter
fun fromQueueStatus(value: QueueStatus): String = value.name
@TypeConverter
fun toQueueStatus(value: String): QueueStatus = QueueStatus.valueOf(value)
}

View file

@ -0,0 +1,42 @@
package com.swoosh.microblog.data.db
import androidx.room.*
import com.swoosh.microblog.data.model.LocalPost
import com.swoosh.microblog.data.model.QueueStatus
import kotlinx.coroutines.flow.Flow
@Dao
interface LocalPostDao {
@Query("SELECT * FROM local_posts ORDER BY updatedAt DESC")
fun getAllPosts(): Flow<List<LocalPost>>
@Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC")
suspend fun getQueuedPosts(
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
): List<LocalPost>
@Query("SELECT * FROM local_posts WHERE localId = :localId")
suspend fun getPostById(localId: Long): LocalPost?
@Query("SELECT * FROM local_posts WHERE ghostId = :ghostId LIMIT 1")
suspend fun getPostByGhostId(ghostId: String): LocalPost?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPost(post: LocalPost): Long
@Update
suspend fun updatePost(post: LocalPost)
@Delete
suspend fun deletePost(post: LocalPost)
@Query("DELETE FROM local_posts WHERE localId = :localId")
suspend fun deleteById(localId: Long)
@Query("UPDATE local_posts SET queueStatus = :status WHERE localId = :localId")
suspend fun updateQueueStatus(localId: Long, status: QueueStatus)
@Query("UPDATE local_posts SET ghostId = :ghostId, queueStatus = :status WHERE localId = :localId")
suspend fun markUploaded(localId: Long, ghostId: String, status: QueueStatus = QueueStatus.NONE)
}

View file

@ -0,0 +1,115 @@
package com.swoosh.microblog.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.google.gson.annotations.SerializedName
// --- API Response/Request Models ---
data class PostsResponse(
val posts: List<GhostPost>,
val meta: Meta?
)
data class Meta(
val pagination: Pagination?
)
data class Pagination(
val page: Int,
val limit: Int,
val pages: Int,
val total: Int,
val next: Int?,
val prev: Int?
)
data class PostWrapper(
val posts: List<GhostPost>
)
data class GhostPost(
val id: String? = null,
val title: String? = null,
val html: String? = null,
val plaintext: String? = null,
val mobiledoc: String? = null,
val status: String? = null,
val feature_image: String? = null,
val created_at: String? = null,
val updated_at: String? = null,
val published_at: String? = null,
val custom_excerpt: String? = null,
val visibility: String? = "public",
val authors: List<Author>? = null
)
data class Author(
val id: String,
val name: String?
)
// --- Local Database Entity ---
@Entity(tableName = "local_posts")
data class LocalPost(
@PrimaryKey(autoGenerate = true)
val localId: Long = 0,
val ghostId: String? = null,
val title: String = "",
val content: String = "",
val htmlContent: String? = null,
val status: PostStatus = PostStatus.DRAFT,
val imageUri: String? = null,
val uploadedImageUrl: String? = null,
val linkUrl: String? = null,
val linkTitle: String? = null,
val linkDescription: String? = null,
val linkImageUrl: String? = null,
val scheduledAt: String? = null,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val queueStatus: QueueStatus = QueueStatus.NONE
)
enum class PostStatus {
DRAFT,
PUBLISHED,
SCHEDULED
}
enum class QueueStatus {
NONE,
QUEUED_PUBLISH,
QUEUED_SCHEDULED,
UPLOADING,
FAILED
}
// --- UI Display Model ---
data class FeedPost(
val localId: Long? = null,
val ghostId: String? = null,
val title: String,
val textContent: String,
val htmlContent: String?,
val imageUrl: String?,
val linkUrl: String?,
val linkTitle: String?,
val linkDescription: String?,
val linkImageUrl: String?,
val status: String,
val publishedAt: String?,
val createdAt: String?,
val updatedAt: String?,
val isLocal: Boolean = false,
val queueStatus: QueueStatus = QueueStatus.NONE
)
data class LinkPreview(
val url: String,
val title: String?,
val description: String?,
val imageUrl: String?
)

View file

@ -0,0 +1,33 @@
package com.swoosh.microblog.data.repository
import com.swoosh.microblog.data.model.LinkPreview
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
object OpenGraphFetcher {
suspend fun fetch(url: String): LinkPreview? = withContext(Dispatchers.IO) {
try {
val doc = Jsoup.connect(url)
.userAgent("Swoosh/1.0")
.timeout(10_000)
.get()
val title = doc.select("meta[property=og:title]").attr("content")
.ifBlank { doc.title() }
val description = doc.select("meta[property=og:description]").attr("content")
.ifBlank { doc.select("meta[name=description]").attr("content") }
val imageUrl = doc.select("meta[property=og:image]").attr("content")
LinkPreview(
url = url,
title = title.ifBlank { null },
description = description.ifBlank { null },
imageUrl = imageUrl.ifBlank { null }
)
} catch (e: Exception) {
null
}
}
}

View file

@ -0,0 +1,153 @@
package com.swoosh.microblog.data.repository
import android.content.Context
import android.net.Uri
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.api.GhostApiService
import com.swoosh.microblog.data.db.AppDatabase
import com.swoosh.microblog.data.db.LocalPostDao
import com.swoosh.microblog.data.model.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.io.FileOutputStream
class PostRepository(private val context: Context) {
private val credentials = CredentialsManager(context)
private val dao: LocalPostDao = AppDatabase.getInstance(context).localPostDao()
private fun getApi(): GhostApiService {
val url = credentials.ghostUrl ?: throw IllegalStateException("Ghost URL not configured")
return ApiClient.getService(url) { credentials.adminApiKey }
}
// --- Remote operations ---
suspend fun fetchPosts(page: Int = 1, limit: Int = 15): Result<PostsResponse> =
withContext(Dispatchers.IO) {
try {
val response = getApi().getPosts(limit = limit, page = page)
if (response.isSuccessful) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun createPost(post: GhostPost): Result<GhostPost> =
withContext(Dispatchers.IO) {
try {
val response = getApi().createPost(PostWrapper(listOf(post)))
if (response.isSuccessful) {
Result.success(response.body()!!.posts.first())
} else {
Result.failure(Exception("Create failed ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun updatePost(id: String, post: GhostPost): Result<GhostPost> =
withContext(Dispatchers.IO) {
try {
val response = getApi().updatePost(id, PostWrapper(listOf(post)))
if (response.isSuccessful) {
Result.success(response.body()!!.posts.first())
} else {
Result.failure(Exception("Update failed ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun deletePost(id: String): Result<Unit> =
withContext(Dispatchers.IO) {
try {
val response = getApi().deletePost(id)
if (response.isSuccessful) {
Result.success(Unit)
} else {
Result.failure(Exception("Delete failed ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun uploadImage(uri: Uri): Result<String> =
withContext(Dispatchers.IO) {
try {
val file = copyUriToTempFile(uri)
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val requestBody = file.asRequestBody(mimeType.toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
val purpose = "image".toRequestBody("text/plain".toMediaType())
val response = getApi().uploadImage(part, purpose)
file.delete()
if (response.isSuccessful) {
val url = response.body()!!.images.first().url
Result.success(url)
} else {
Result.failure(Exception("Upload failed ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
private fun copyUriToTempFile(uri: Uri): File {
val inputStream = context.contentResolver.openInputStream(uri)
?: throw IllegalStateException("Cannot open URI")
val tempFile = File.createTempFile("upload_", ".jpg", context.cacheDir)
FileOutputStream(tempFile).use { output ->
inputStream.copyTo(output)
}
inputStream.close()
return tempFile
}
// --- Local operations ---
fun getLocalPosts(): Flow<List<LocalPost>> = dao.getAllPosts()
suspend fun getQueuedPosts(): List<LocalPost> = dao.getQueuedPosts()
suspend fun saveLocalPost(post: LocalPost): Long = dao.insertPost(post)
suspend fun updateLocalPost(post: LocalPost) = dao.updatePost(post)
suspend fun deleteLocalPost(localId: Long) = dao.deleteById(localId)
suspend fun getLocalPostById(localId: Long): LocalPost? = dao.getPostById(localId)
suspend fun getLocalPostByGhostId(ghostId: String): LocalPost? = dao.getPostByGhostId(ghostId)
suspend fun updateQueueStatus(localId: Long, status: QueueStatus) =
dao.updateQueueStatus(localId, status)
suspend fun markUploaded(localId: Long, ghostId: String) =
dao.markUploaded(localId, ghostId)
// --- Connectivity check ---
fun isNetworkAvailable(): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
val network = cm.activeNetwork ?: return false
val capabilities = cm.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}

View file

@ -0,0 +1,355 @@
package com.swoosh.microblog.ui.composer
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ComposerScreen(
editPost: FeedPost? = null,
onDismiss: () -> Unit,
viewModel: ComposerViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var showLinkDialog by remember { mutableStateOf(false) }
var showDatePicker by remember { mutableStateOf(false) }
var linkInput by remember { mutableStateOf("") }
// Load post for editing
LaunchedEffect(editPost) {
if (editPost != null) {
viewModel.loadForEdit(editPost)
}
}
LaunchedEffect(state.isSuccess) {
if (state.isSuccess) {
viewModel.reset()
onDismiss()
}
}
val imagePickerLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
viewModel.setImage(uri)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (state.isEditing) "Edit Post" else "New Post") },
navigationIcon = {
IconButton(onClick = {
viewModel.reset()
onDismiss()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Text field with character counter
OutlinedTextField(
value = state.text,
onValueChange = viewModel::updateText,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 150.dp),
placeholder = { Text("What's on your mind?") },
supportingText = {
Text(
"${state.text.length} characters",
style = MaterialTheme.typography.labelSmall,
color = if (state.text.length > 280)
MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
Spacer(modifier = Modifier.height(12.dp))
// Attachment buttons row
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedIconButton(onClick = { imagePickerLauncher.launch("image/*") }) {
Icon(Icons.Default.Image, "Attach image")
}
OutlinedIconButton(onClick = { showLinkDialog = true }) {
Icon(Icons.Default.Link, "Add link")
}
}
// Image preview
if (state.imageUri != null) {
Spacer(modifier = Modifier.height(12.dp))
Box {
AsyncImage(
model = state.imageUri,
contentDescription = "Selected image",
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(MaterialTheme.shapes.medium),
contentScale = ContentScale.Crop
)
IconButton(
onClick = { viewModel.setImage(null) },
modifier = Modifier.align(Alignment.TopEnd)
) {
Icon(
Icons.Default.Close, "Remove image",
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}
// Link preview
if (state.isLoadingLink) {
Spacer(modifier = Modifier.height(12.dp))
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (state.linkPreview != null) {
Spacer(modifier = Modifier.height(12.dp))
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = state.linkPreview!!.title ?: state.linkPreview!!.url,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f)
)
IconButton(onClick = viewModel::removeLinkPreview) {
Icon(Icons.Default.Close, "Remove link", Modifier.size(18.dp))
}
}
if (state.linkPreview!!.description != null) {
Text(
text = state.linkPreview!!.description!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
if (state.linkPreview!!.imageUrl != null) {
Spacer(modifier = Modifier.height(8.dp))
AsyncImage(
model = state.linkPreview!!.imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Crop
)
}
}
}
}
// Scheduled time display
if (state.scheduledAt != null) {
Spacer(modifier = Modifier.height(12.dp))
AssistChip(
onClick = { showDatePicker = true },
label = { Text("Scheduled: ${state.scheduledAt}") },
leadingIcon = { Icon(Icons.Default.Schedule, null, Modifier.size(18.dp)) },
trailingIcon = {
IconButton(
onClick = { viewModel.setScheduledDate(null) },
modifier = Modifier.size(18.dp)
) {
Icon(Icons.Default.Close, "Clear schedule", Modifier.size(14.dp))
}
}
)
}
if (state.error != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.weight(1f))
// Action buttons
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = viewModel::publish,
modifier = Modifier.fillMaxWidth(),
enabled = !state.isSubmitting && state.text.isNotBlank()
) {
if (state.isSubmitting) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
}
Text(if (state.isEditing) "Update & Publish" else "Publish now")
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = viewModel::saveDraft,
modifier = Modifier.weight(1f),
enabled = !state.isSubmitting
) {
Text("Save draft")
}
OutlinedButton(
onClick = { showDatePicker = true },
modifier = Modifier.weight(1f),
enabled = !state.isSubmitting
) {
Icon(Icons.Default.Schedule, null, Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text("Schedule")
}
}
}
}
}
// Link dialog
if (showLinkDialog) {
AlertDialog(
onDismissRequest = { showLinkDialog = false },
title = { Text("Add Link") },
text = {
OutlinedTextField(
value = linkInput,
onValueChange = { linkInput = it },
placeholder = { Text("https://...") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(onClick = {
viewModel.fetchLinkPreview(linkInput)
linkInput = ""
showLinkDialog = false
}) {
Text("Fetch Preview")
}
},
dismissButton = {
TextButton(onClick = { showLinkDialog = false }) {
Text("Cancel")
}
}
)
}
// Schedule date picker
if (showDatePicker) {
ScheduleDateTimePicker(
onConfirm = { dateTime ->
val isoString = dateTime.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
viewModel.setScheduledDate(isoString)
showDatePicker = false
// Auto-submit as scheduled
viewModel.schedule()
},
onDismiss = { showDatePicker = false }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScheduleDateTimePicker(
onConfirm: (LocalDateTime) -> Unit,
onDismiss: () -> Unit
) {
val datePickerState = rememberDatePickerState()
var showTimePicker by remember { mutableStateOf(false) }
if (!showTimePicker) {
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
if (datePickerState.selectedDateMillis != null) {
showTimePicker = true
}
}) { Text("Next") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
) {
DatePicker(state = datePickerState)
}
} else {
val timePickerState = rememberTimePickerState()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Select Time") },
text = {
TimePicker(state = timePickerState)
},
confirmButton = {
TextButton(onClick = {
val millis = datePickerState.selectedDateMillis!!
val date = java.time.Instant.ofEpochMilli(millis)
.atZone(ZoneId.systemDefault())
.toLocalDate()
val dateTime = date.atTime(timePickerState.hour, timePickerState.minute)
onConfirm(dateTime)
}) { Text("Schedule") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
}

View file

@ -0,0 +1,207 @@
package com.swoosh.microblog.ui.composer
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.OpenGraphFetcher
import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.worker.PostUploadWorker
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 ComposerViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application)
private val appContext = application
private val _uiState = MutableStateFlow(ComposerUiState())
val uiState: StateFlow<ComposerUiState> = _uiState.asStateFlow()
private var editingLocalId: Long? = null
private var editingGhostId: String? = null
private var editingUpdatedAt: String? = null
fun loadForEdit(post: FeedPost) {
editingLocalId = post.localId
editingGhostId = post.ghostId
editingUpdatedAt = post.updatedAt
_uiState.update {
it.copy(
text = post.textContent,
imageUri = post.imageUrl?.let { url -> Uri.parse(url) },
linkPreview = if (post.linkUrl != null) LinkPreview(
url = post.linkUrl,
title = post.linkTitle,
description = post.linkDescription,
imageUrl = post.linkImageUrl
) else null,
isEditing = true
)
}
}
fun updateText(text: String) {
_uiState.update { it.copy(text = text) }
}
fun setImage(uri: Uri?) {
_uiState.update { it.copy(imageUri = uri) }
}
fun fetchLinkPreview(url: String) {
if (url.isBlank()) return
viewModelScope.launch {
_uiState.update { it.copy(isLoadingLink = true) }
val preview = OpenGraphFetcher.fetch(url)
_uiState.update { it.copy(linkPreview = preview, isLoadingLink = false) }
}
}
fun removeLinkPreview() {
_uiState.update { it.copy(linkPreview = null) }
}
fun setScheduledDate(dateTimeIso: String?) {
_uiState.update { it.copy(scheduledAt = dateTimeIso) }
}
fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH)
fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE)
fun schedule() {
val scheduledAt = _uiState.value.scheduledAt ?: return
submitPost(PostStatus.SCHEDULED, QueueStatus.QUEUED_SCHEDULED)
}
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
val state = _uiState.value
if (state.text.isBlank() && state.imageUri == null) return
viewModelScope.launch {
_uiState.update { it.copy(isSubmitting = true, error = null) }
val title = state.text.take(60)
if (status == PostStatus.DRAFT || !repository.isNetworkAvailable()) {
// Save locally
val localPost = LocalPost(
localId = editingLocalId ?: 0,
ghostId = editingGhostId,
title = title,
content = state.text,
status = status,
imageUri = state.imageUri?.toString(),
linkUrl = state.linkPreview?.url,
linkTitle = state.linkPreview?.title,
linkDescription = state.linkPreview?.description,
linkImageUrl = state.linkPreview?.imageUrl,
scheduledAt = state.scheduledAt,
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus
)
repository.saveLocalPost(localPost)
if (status != PostStatus.DRAFT) {
PostUploadWorker.enqueue(appContext)
}
_uiState.update { it.copy(isSubmitting = false, isSuccess = true) }
return@launch
}
// Online: upload image first if needed
var featureImage: String? = null
if (state.imageUri != null) {
repository.uploadImage(state.imageUri).fold(
onSuccess = { url -> featureImage = url },
onFailure = { e ->
_uiState.update { it.copy(isSubmitting = false, error = "Image upload failed: ${e.message}") }
return@launch
}
)
}
val mobiledoc = buildMobiledoc(state.text, state.linkPreview)
val ghostPost = GhostPost(
title = title,
mobiledoc = mobiledoc,
status = status.name.lowercase(),
feature_image = featureImage,
published_at = state.scheduledAt,
visibility = "public"
)
val result = if (editingGhostId != null) {
val updatePost = ghostPost.copy(updated_at = editingUpdatedAt)
repository.updatePost(editingGhostId!!, updatePost)
} else {
repository.createPost(ghostPost)
}
result.fold(
onSuccess = {
// Clean up local copy if exists
editingLocalId?.let { repository.deleteLocalPost(it) }
_uiState.update { it.copy(isSubmitting = false, isSuccess = true) }
},
onFailure = { e ->
// Save to offline queue on failure
val localPost = LocalPost(
title = title,
content = state.text,
status = status,
imageUri = state.imageUri?.toString(),
uploadedImageUrl = featureImage,
linkUrl = state.linkPreview?.url,
linkTitle = state.linkPreview?.title,
linkDescription = state.linkPreview?.description,
linkImageUrl = state.linkPreview?.imageUrl,
scheduledAt = state.scheduledAt,
queueStatus = offlineQueueStatus
)
repository.saveLocalPost(localPost)
PostUploadWorker.enqueue(appContext)
_uiState.update { it.copy(isSubmitting = false, error = "Saved to queue: ${e.message}") }
}
)
}
}
private fun buildMobiledoc(text: String, linkPreview: LinkPreview?): String {
val escapedText = text.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
val cards = if (linkPreview != null) {
val escapedUrl = linkPreview.url.replace("\\", "\\\\").replace("\"", "\\\"")
val escapedTitle = linkPreview.title?.replace("\\", "\\\\")?.replace("\"", "\\\"") ?: ""
val escapedDesc = linkPreview.description?.replace("\\", "\\\\")?.replace("\"", "\\\"") ?: ""
""",[\"bookmark\",{\"url\":\"$escapedUrl\",\"metadata\":{\"title\":\"$escapedTitle\",\"description\":\"$escapedDesc\"}}]"""
} else ""
return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]${if (linkPreview != null) ",[10,0]" else ""}}"""
}
fun reset() {
editingLocalId = null
editingGhostId = null
editingUpdatedAt = null
_uiState.value = ComposerUiState()
}
}
data class ComposerUiState(
val text: String = "",
val imageUri: Uri? = null,
val linkPreview: LinkPreview? = null,
val isLoadingLink: Boolean = false,
val scheduledAt: String? = null,
val isSubmitting: Boolean = false,
val isSuccess: Boolean = false,
val isEditing: Boolean = false,
val error: String? = null
)

View file

@ -0,0 +1,189 @@
package com.swoosh.microblog.ui.detail
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.ui.feed.StatusBadge
import com.swoosh.microblog.ui.feed.formatRelativeTime
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DetailScreen(
post: FeedPost,
onBack: () -> Unit,
onEdit: (FeedPost) -> Unit,
onDelete: (FeedPost) -> Unit
) {
var showDeleteDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Post") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = { onEdit(post) }) {
Icon(Icons.Default.Edit, "Edit")
}
IconButton(onClick = { showDeleteDialog = true }) {
Icon(Icons.Default.Delete, "Delete")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Status and time
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
StatusBadge(post)
Text(
text = formatRelativeTime(post.publishedAt ?: post.createdAt),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(16.dp))
// Full text content
Text(
text = post.textContent,
style = MaterialTheme.typography.bodyLarge
)
// Full image
if (post.imageUrl != null) {
Spacer(modifier = Modifier.height(16.dp))
AsyncImage(
model = post.imageUrl,
contentDescription = "Post image",
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium),
contentScale = ContentScale.FillWidth
)
}
// Link preview
if (post.linkUrl != null) {
Spacer(modifier = Modifier.height(16.dp))
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
if (post.linkImageUrl != null) {
AsyncImage(
model = post.linkImageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(8.dp))
}
if (post.linkTitle != null) {
Text(
text = post.linkTitle,
style = MaterialTheme.typography.titleMedium
)
}
if (post.linkDescription != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = post.linkDescription,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = post.linkUrl,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
// Metadata
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(12.dp))
if (post.createdAt != null) {
MetadataRow("Created", post.createdAt)
}
if (post.publishedAt != null) {
MetadataRow("Published", post.publishedAt)
}
MetadataRow("Status", post.status.replaceFirstChar { it.uppercase() })
}
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Post") },
text = { Text("Are you sure you want to delete this post? This action cannot be undone.") },
confirmButton = {
TextButton(
onClick = {
showDeleteDialog = false
onDelete(post)
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") }
}
)
}
}
@Composable
private fun MetadataRow(label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium
)
}
}

View file

@ -0,0 +1,286 @@
package com.swoosh.microblog.ui.feed
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.QueueStatus
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FeedScreen(
onSettingsClick: () -> Unit,
onPostClick: (FeedPost) -> Unit,
onCompose: () -> Unit,
viewModel: FeedViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
// Infinite scroll trigger
val shouldLoadMore by remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
lastVisibleItem >= state.posts.size - 3
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore && state.posts.isNotEmpty()) {
viewModel.loadMore()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Swoosh") },
actions = {
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = onCompose) {
Icon(Icons.Default.Add, contentDescription = "New post")
}
}
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
if (state.posts.isEmpty() && !state.isRefreshing) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No posts yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Tap + to write your first post",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = viewModel::refresh,
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
PostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) }
)
}
if (state.isLoadingMore) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
}
}
if (state.error != null) {
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = {
TextButton(onClick = viewModel::clearError) { Text("Dismiss") }
}
) {
Text(state.error!!)
}
}
}
}
}
@Composable
fun PostCard(
post: FeedPost,
onClick: () -> Unit,
onCancelQueue: () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val displayText = if (expanded || post.textContent.length <= 280) {
post.textContent
} else {
post.textContent.take(280) + "..."
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(modifier = Modifier.padding(16.dp)) {
// Status row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
StatusBadge(post)
Text(
text = formatRelativeTime(post.publishedAt ?: post.createdAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
// Content
Text(
text = displayText,
style = MaterialTheme.typography.bodyMedium,
maxLines = if (expanded) Int.MAX_VALUE else 8,
overflow = TextOverflow.Ellipsis
)
if (!expanded && post.textContent.length > 280) {
TextButton(
onClick = { expanded = true },
contentPadding = PaddingValues(0.dp)
) {
Text("Show more", style = MaterialTheme.typography.labelMedium)
}
}
// Image thumbnail
if (post.imageUrl != null) {
Spacer(modifier = Modifier.height(8.dp))
AsyncImage(
model = post.imageUrl,
contentDescription = "Post image",
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(MaterialTheme.shapes.medium),
contentScale = ContentScale.Crop
)
}
// Link preview
if (post.linkUrl != null && post.linkTitle != null) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
if (post.linkImageUrl != null) {
AsyncImage(
model = post.linkImageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(8.dp))
}
Text(
text = post.linkTitle,
style = MaterialTheme.typography.titleSmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (post.linkDescription != null) {
Text(
text = post.linkDescription,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
// Queue status
if (post.queueStatus != QueueStatus.NONE) {
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
val queueLabel = when (post.queueStatus) {
QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload"
QueueStatus.UPLOADING -> "Uploading..."
QueueStatus.FAILED -> "Upload failed"
else -> ""
}
AssistChip(
onClick = {},
label = { Text(queueLabel, style = MaterialTheme.typography.labelSmall) }
)
Spacer(modifier = Modifier.width(8.dp))
if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED) {
TextButton(onClick = onCancelQueue) {
Text("Cancel", style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
}
}
@Composable
fun StatusBadge(post: FeedPost) {
val (label, color) = when {
post.queueStatus != QueueStatus.NONE -> "Pending" to MaterialTheme.colorScheme.tertiary
post.status == "published" -> "Published" to MaterialTheme.colorScheme.primary
post.status == "scheduled" -> "Scheduled" to MaterialTheme.colorScheme.secondary
else -> "Draft" to MaterialTheme.colorScheme.outline
}
SuggestionChip(
onClick = {},
label = {
Text(label, style = MaterialTheme.typography.labelSmall)
},
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = color.copy(alpha = 0.12f),
labelColor = color
),
border = null
)
}

View file

@ -0,0 +1,184 @@
package com.swoosh.microblog.ui.feed
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.PostRepository
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application)
private val _uiState = MutableStateFlow(FeedUiState())
val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
private var currentPage = 1
private var hasMorePages = true
private var remotePosts = listOf<FeedPost>()
init {
observeLocalPosts()
refresh()
}
private fun observeLocalPosts() {
viewModelScope.launch {
repository.getLocalPosts().collect { localPosts ->
val queuedPosts = localPosts
.filter { it.queueStatus != QueueStatus.NONE }
.map { it.toFeedPost() }
mergePosts(queuedPosts)
}
}
}
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true, error = null) }
currentPage = 1
hasMorePages = true
repository.fetchPosts(page = 1).fold(
onSuccess = { response ->
remotePosts = response.posts.map { it.toFeedPost() }
hasMorePages = response.meta?.pagination?.next != null
mergePosts()
_uiState.update { it.copy(isRefreshing = false) }
},
onFailure = { e ->
_uiState.update { it.copy(isRefreshing = false, error = e.message) }
}
)
}
}
fun loadMore() {
if (!hasMorePages || _uiState.value.isLoadingMore) return
viewModelScope.launch {
_uiState.update { it.copy(isLoadingMore = true) }
currentPage++
repository.fetchPosts(page = currentPage).fold(
onSuccess = { response ->
val newPosts = response.posts.map { it.toFeedPost() }
remotePosts = remotePosts + newPosts
hasMorePages = response.meta?.pagination?.next != null
mergePosts()
_uiState.update { it.copy(isLoadingMore = false) }
},
onFailure = {
currentPage--
_uiState.update { it.copy(isLoadingMore = false) }
}
)
}
}
fun deletePost(post: FeedPost) {
viewModelScope.launch {
if (post.isLocal && post.localId != null) {
repository.deleteLocalPost(post.localId)
}
if (post.ghostId != null) {
repository.deletePost(post.ghostId).fold(
onSuccess = { refresh() },
onFailure = { e ->
_uiState.update { it.copy(error = "Delete failed: ${e.message}") }
}
)
}
}
}
fun cancelQueuedPost(post: FeedPost) {
viewModelScope.launch {
post.localId?.let { repository.deleteLocalPost(it) }
}
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
private fun mergePosts(queuedPosts: List<FeedPost>? = null) {
val queued = queuedPosts ?: _uiState.value.posts.filter { it.isLocal }
val allPosts = queued + remotePosts
_uiState.update { it.copy(posts = allPosts) }
}
private fun GhostPost.toFeedPost(): FeedPost = FeedPost(
ghostId = id,
title = title ?: "",
textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "",
htmlContent = html,
imageUrl = feature_image,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = status ?: "draft",
publishedAt = published_at,
createdAt = created_at,
updatedAt = updated_at,
isLocal = false
)
private fun LocalPost.toFeedPost(): FeedPost = FeedPost(
localId = localId,
ghostId = ghostId,
title = title,
textContent = content,
htmlContent = htmlContent,
imageUrl = uploadedImageUrl ?: imageUri,
linkUrl = linkUrl,
linkTitle = linkTitle,
linkDescription = linkDescription,
linkImageUrl = linkImageUrl,
status = status.name.lowercase(),
publishedAt = null,
createdAt = null,
updatedAt = null,
isLocal = true,
queueStatus = queueStatus
)
}
data class FeedUiState(
val posts: List<FeedPost> = emptyList(),
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val error: String? = null
)
fun formatRelativeTime(isoString: String?): String {
if (isoString == null) return ""
return try {
val instant = try {
ZonedDateTime.parse(isoString).toInstant()
} catch (e: Exception) {
Instant.parse(isoString)
}
val now = Instant.now()
val minutes = ChronoUnit.MINUTES.between(instant, now)
val hours = ChronoUnit.HOURS.between(instant, now)
val days = ChronoUnit.DAYS.between(instant, now)
when {
minutes < 1 -> "now"
minutes < 60 -> "${minutes}m ago"
hours < 24 -> "${hours}h ago"
days < 7 -> "${days}d ago"
else -> DateTimeFormatter.ofPattern("MMM d").format(ZonedDateTime.ofInstant(instant, java.time.ZoneId.systemDefault()))
}
} catch (e: Exception) {
""
}
}

View file

@ -0,0 +1,103 @@
package com.swoosh.microblog.ui.navigation
import androidx.compose.runtime.*
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.ui.composer.ComposerScreen
import com.swoosh.microblog.ui.composer.ComposerViewModel
import com.swoosh.microblog.ui.detail.DetailScreen
import com.swoosh.microblog.ui.feed.FeedScreen
import com.swoosh.microblog.ui.feed.FeedViewModel
import com.swoosh.microblog.ui.settings.SettingsScreen
import com.swoosh.microblog.ui.setup.SetupScreen
object Routes {
const val SETUP = "setup"
const val FEED = "feed"
const val COMPOSER = "composer"
const val DETAIL = "detail"
const val SETTINGS = "settings"
}
@Composable
fun SwooshNavGraph(
navController: NavHostController,
startDestination: String
) {
// Shared state for passing posts between screens
var selectedPost by remember { mutableStateOf<FeedPost?>(null) }
var editPost by remember { mutableStateOf<FeedPost?>(null) }
val feedViewModel: FeedViewModel = viewModel()
NavHost(navController = navController, startDestination = startDestination) {
composable(Routes.SETUP) {
SetupScreen(
onSetupComplete = {
navController.navigate(Routes.FEED) {
popUpTo(Routes.SETUP) { inclusive = true }
}
}
)
}
composable(Routes.FEED) {
FeedScreen(
viewModel = feedViewModel,
onSettingsClick = { navController.navigate(Routes.SETTINGS) },
onPostClick = { post ->
selectedPost = post
navController.navigate(Routes.DETAIL)
},
onCompose = {
editPost = null
navController.navigate(Routes.COMPOSER)
}
)
}
composable(Routes.COMPOSER) {
val composerViewModel: ComposerViewModel = viewModel()
ComposerScreen(
editPost = editPost,
viewModel = composerViewModel,
onDismiss = {
feedViewModel.refresh()
navController.popBackStack()
}
)
}
composable(Routes.DETAIL) {
val post = selectedPost
if (post != null) {
DetailScreen(
post = post,
onBack = { navController.popBackStack() },
onEdit = { p ->
editPost = p
navController.navigate(Routes.COMPOSER)
},
onDelete = { p ->
feedViewModel.deletePost(p)
navController.popBackStack()
}
)
}
}
composable(Routes.SETTINGS) {
SettingsScreen(
onBack = { navController.popBackStack() },
onLogout = {
navController.navigate(Routes.SETUP) {
popUpTo(0) { inclusive = true }
}
}
)
}
}
}

View file

@ -0,0 +1,112 @@
package com.swoosh.microblog.ui.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.api.ApiClient
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onLogout: () -> Unit
) {
val context = LocalContext.current
val credentials = remember { CredentialsManager(context) }
var url by remember { mutableStateOf(credentials.ghostUrl ?: "") }
var apiKey by remember { mutableStateOf(credentials.adminApiKey ?: "") }
var saved by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
) {
Text("Ghost Instance", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = url,
onValueChange = { url = it; saved = false },
label = { Text("Ghost URL") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = apiKey,
onValueChange = { apiKey = it; saved = false },
label = { Text("Admin API Key") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
credentials.ghostUrl = url
credentials.adminApiKey = apiKey
ApiClient.reset()
saved = true
},
modifier = Modifier.fillMaxWidth()
) {
Text("Save Changes")
}
if (saved) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Settings saved",
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(32.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = {
credentials.clear()
ApiClient.reset()
onLogout()
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Disconnect & Reset")
}
}
}
}

View file

@ -0,0 +1,107 @@
package com.swoosh.microblog.ui.setup
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SetupScreen(
onSetupComplete: () -> Unit,
viewModel: SetupViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.isSuccess) {
if (state.isSuccess) onSetupComplete()
}
Scaffold(
topBar = {
TopAppBar(title = { Text("Setup Ghost Instance") })
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Swoosh",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Connect to your Ghost instance",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = state.url,
onValueChange = viewModel::updateUrl,
label = { Text("Ghost Instance URL") },
placeholder = { Text("https://your-blog.ghost.io") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = state.apiKey,
onValueChange = viewModel::updateApiKey,
label = { Text("Admin API Key") },
placeholder = { Text("key_id:secret") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
if (state.error != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = viewModel::save,
enabled = !state.isTesting,
modifier = Modifier.fillMaxWidth()
) {
if (state.isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("Testing connection...")
} else {
Text("Connect")
}
}
}
}
}

View file

@ -0,0 +1,80 @@
package com.swoosh.microblog.ui.setup
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.api.GhostJwtGenerator
import com.swoosh.microblog.data.repository.PostRepository
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 SetupViewModel(application: Application) : AndroidViewModel(application) {
private val credentials = CredentialsManager(application)
private val _uiState = MutableStateFlow(SetupUiState(
url = credentials.ghostUrl ?: "",
apiKey = credentials.adminApiKey ?: ""
))
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
fun updateUrl(url: String) {
_uiState.update { it.copy(url = url) }
}
fun updateApiKey(key: String) {
_uiState.update { it.copy(apiKey = key) }
}
fun save() {
val state = _uiState.value
if (state.url.isBlank() || state.apiKey.isBlank()) {
_uiState.update { it.copy(error = "Both fields are required") }
return
}
// Validate API key format
val parts = state.apiKey.split(":")
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) {
_uiState.update { it.copy(error = "Invalid API key format. Expected {id}:{secret}") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isTesting = true, error = null) }
// Test connection
try {
GhostJwtGenerator.generateToken(state.apiKey)
credentials.ghostUrl = state.url
credentials.adminApiKey = state.apiKey
ApiClient.reset()
val repo = PostRepository(getApplication())
repo.fetchPosts(page = 1, limit = 1).fold(
onSuccess = {
_uiState.update { it.copy(isTesting = false, isSuccess = true) }
},
onFailure = { e ->
_uiState.update { it.copy(isTesting = false, error = "Connection failed: ${e.message}") }
}
)
} catch (e: Exception) {
_uiState.update { it.copy(isTesting = false, error = "Invalid API key: ${e.message}") }
}
}
}
}
data class SetupUiState(
val url: String = "",
val apiKey: String = "",
val isTesting: Boolean = false,
val isSuccess: Boolean = false,
val error: String? = null
)

View file

@ -0,0 +1,30 @@
package com.swoosh.microblog.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
fun SwooshTheme(
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,137 @@
package com.swoosh.microblog.worker
import android.content.Context
import android.net.Uri
import androidx.work.*
import com.swoosh.microblog.data.model.GhostPost
import com.swoosh.microblog.data.model.QueueStatus
import com.swoosh.microblog.data.repository.PostRepository
import java.util.concurrent.TimeUnit
class PostUploadWorker(
context: Context,
workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
val repository = PostRepository(applicationContext)
val queuedPosts = repository.getQueuedPosts()
if (queuedPosts.isEmpty()) return Result.success()
var allSuccess = true
for (post in queuedPosts) {
repository.updateQueueStatus(post.localId, QueueStatus.UPLOADING)
// Upload image if needed
var featureImage = post.uploadedImageUrl
if (featureImage == null && post.imageUri != null) {
val imageResult = repository.uploadImage(Uri.parse(post.imageUri))
imageResult.fold(
onSuccess = { url -> featureImage = url },
onFailure = {
repository.updateQueueStatus(post.localId, QueueStatus.FAILED)
allSuccess = false
continue
}
)
}
val mobiledoc = buildMobiledoc(post.content, post.linkUrl, post.linkTitle, post.linkDescription)
val ghostPost = GhostPost(
title = post.title,
mobiledoc = mobiledoc,
status = when (post.queueStatus) {
QueueStatus.QUEUED_PUBLISH -> "published"
QueueStatus.QUEUED_SCHEDULED -> "scheduled"
else -> "draft"
},
feature_image = featureImage,
published_at = post.scheduledAt,
visibility = "public"
)
val result = if (post.ghostId != null) {
repository.updatePost(post.ghostId, ghostPost)
} else {
repository.createPost(ghostPost)
}
result.fold(
onSuccess = { created ->
repository.markUploaded(post.localId, created.id ?: "")
// Delete local copy after successful upload
repository.deleteLocalPost(post.localId)
},
onFailure = {
repository.updateQueueStatus(post.localId, QueueStatus.FAILED)
allSuccess = false
}
)
}
return if (allSuccess) Result.success() else Result.retry()
}
private fun buildMobiledoc(
text: String,
linkUrl: String?,
linkTitle: String?,
linkDescription: String?
): String {
val escapedText = text.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
val cards = if (linkUrl != null) {
val escapedUrl = linkUrl.replace("\\", "\\\\").replace("\"", "\\\"")
val escapedTitle = linkTitle?.replace("\\", "\\\\")?.replace("\"", "\\\"") ?: ""
val escapedDesc = linkDescription?.replace("\\", "\\\\")?.replace("\"", "\\\"") ?: ""
""",[\"bookmark\",{\"url\":\"$escapedUrl\",\"metadata\":{\"title\":\"$escapedTitle\",\"description\":\"$escapedDesc\"}}]"""
} else ""
return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]${if (linkUrl != null) ",[10,0]" else ""}}"""
}
companion object {
private const val WORK_NAME = "post_upload"
private const val PERIODIC_WORK_NAME = "post_upload_periodic"
fun enqueue(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<PostUploadWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
fun enqueuePeriodicCheck(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = PeriodicWorkRequestBuilder<PostUploadWorker>(
15, TimeUnit.MINUTES
)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
PERIODIC_WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFBFE"
android:pathData="M0,0h108v108h-108z" />
</vector>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#6750A4"
android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0" />
<path
android:fillColor="#FFFFFF"
android:pathData="M38,42 C38,38 42,34 50,34 C58,34 65,38 68,44 C71,50 68,56 62,58 C58,60 54,58 54,54 C54,50 58,48 60,50 C62,52 60,54 58,52"
android:strokeWidth="3"
android:strokeColor="#FFFFFF"
android:fillType="nonZero" />
<path
android:fillColor="#FFFFFF"
android:pathData="M34,68 L42,48 L46,68 L54,52 L58,68 L66,44 L74,68"
android:strokeWidth="2.5"
android:strokeColor="#FFFFFF"
android:fillColor="@android:color/transparent" />
</vector>

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="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Swoosh</string>
</resources>

View file

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

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- Allow cleartext for localhost/dev only -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
</network-security-config>

5
build.gradle.kts Normal file
View file

@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false
}

4
gradle.properties Normal file
View file

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

View file

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

17
settings.gradle.kts Normal file
View file

@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolution {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Swoosh"
include(":app")