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