mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +00:00
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:
commit
c76174ff5e
38 changed files with 2710 additions and 0 deletions
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
111
app/build.gradle.kts
Normal 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
16
app/proguard-rules.pro
vendored
Normal 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>;
|
||||
}
|
||||
29
app/src/main/AndroidManifest.xml
Normal file
29
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
31
app/src/main/java/com/swoosh/microblog/MainActivity.kt
Normal file
31
app/src/main/java/com/swoosh/microblog/MainActivity.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
app/src/main/java/com/swoosh/microblog/SwooshApp.kt
Normal file
12
app/src/main/java/com/swoosh/microblog/SwooshApp.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
66
app/src/main/java/com/swoosh/microblog/data/api/ApiClient.kt
Normal file
66
app/src/main/java/com/swoosh/microblog/data/api/ApiClient.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/com/swoosh/microblog/data/db/Converters.kt
Normal file
19
app/src/main/java/com/swoosh/microblog/data/db/Converters.kt
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
115
app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt
Normal file
115
app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt
Normal 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?
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
189
app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt
Normal file
189
app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
286
app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt
Normal file
286
app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
184
app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt
Normal file
184
app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt
Normal 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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
103
app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt
Normal file
103
app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
107
app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt
Normal file
107
app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
30
app/src/main/java/com/swoosh/microblog/ui/theme/Theme.kt
Normal file
30
app/src/main/java/com/swoosh/microblog/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
22
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
22
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Swoosh</string>
|
||||
</resources>
|
||||
4
app/src/main/res/values/themes.xml
Normal file
4
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Swoosh" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
app/src/main/res/xml/network_security_config.xml
Normal file
13
app/src/main/res/xml/network_security_config.xml
Normal 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
5
build.gradle.kts
Normal 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
4
gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
17
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolution {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Swoosh"
|
||||
include(":app")
|
||||
Loading…
Reference in a new issue