commit c76174ff5e081c4f53b123d60b65136b5cf20681 Author: Claude Date: Wed Mar 18 22:43:53 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3bdbed --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/app/build diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..ed89eeb --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..4de505b --- /dev/null +++ b/app/proguard-rules.pro @@ -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.* ; +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ec09764 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/swoosh/microblog/MainActivity.kt b/app/src/main/java/com/swoosh/microblog/MainActivity.kt new file mode 100644 index 0000000..da32a06 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/MainActivity.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/SwooshApp.kt b/app/src/main/java/com/swoosh/microblog/SwooshApp.kt new file mode 100644 index 0000000..49d39bb --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/SwooshApp.kt @@ -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) + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/CredentialsManager.kt b/app/src/main/java/com/swoosh/microblog/data/CredentialsManager.kt new file mode 100644 index 0000000..334fd64 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/CredentialsManager.kt @@ -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" + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/api/ApiClient.kt b/app/src/main/java/com/swoosh/microblog/data/api/ApiClient.kt new file mode 100644 index 0000000..f9e8b9a --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/api/ApiClient.kt @@ -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 + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt new file mode 100644 index 0000000..b51ebe8 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt @@ -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 + + @POST("ghost/api/admin/posts/") + @Headers("Content-Type: application/json") + suspend fun createPost( + @Body body: PostWrapper + ): Response + + @PUT("ghost/api/admin/posts/{id}/") + @Headers("Content-Type: application/json") + suspend fun updatePost( + @Path("id") id: String, + @Body body: PostWrapper + ): Response + + @DELETE("ghost/api/admin/posts/{id}/") + suspend fun deletePost( + @Path("id") id: String + ): Response + + @Multipart + @POST("ghost/api/admin/images/upload/") + suspend fun uploadImage( + @Part file: MultipartBody.Part, + @Part("purpose") purpose: RequestBody + ): Response +} + +data class ImageUploadResponse( + val images: List +) + +data class UploadedImage( + val url: String, + val ref: String? +) diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostAuthInterceptor.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostAuthInterceptor.kt new file mode 100644 index 0000000..9e60ff8 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostAuthInterceptor.kt @@ -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) + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostJwtGenerator.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostJwtGenerator.kt new file mode 100644 index 0000000..5fd0479 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostJwtGenerator.kt @@ -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() + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt new file mode 100644 index 0000000..3b3f23f --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/db/Converters.kt b/app/src/main/java/com/swoosh/microblog/data/db/Converters.kt new file mode 100644 index 0000000..e6f4dee --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/db/Converters.kt @@ -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) +} diff --git a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt new file mode 100644 index 0000000..02a5651 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt @@ -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> + + @Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC") + suspend fun getQueuedPosts( + statuses: List = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED) + ): List + + @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) +} diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt new file mode 100644 index 0000000..9adfc02 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -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, + 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 +) + +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? = 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? +) diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/OpenGraphFetcher.kt b/app/src/main/java/com/swoosh/microblog/data/repository/OpenGraphFetcher.kt new file mode 100644 index 0000000..4c817b6 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/repository/OpenGraphFetcher.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt new file mode 100644 index 0000000..8a567ab --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt @@ -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 = + 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 = + 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 = + 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 = + 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 = + 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> = dao.getAllPosts() + + suspend fun getQueuedPosts(): List = 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) + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt new file mode 100644 index 0000000..b26f002 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -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") } + } + ) + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt new file mode 100644 index 0000000..fc1d533 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -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 = _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 +) diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt new file mode 100644 index 0000000..13aa581 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt new file mode 100644 index 0000000..c818a7e --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt new file mode 100644 index 0000000..7d4a175 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -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 = _uiState.asStateFlow() + + private var currentPage = 1 + private var hasMorePages = true + private var remotePosts = listOf() + + 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? = 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 = 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) { + "" + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..a33cc63 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt @@ -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(null) } + var editPost by remember { mutableStateOf(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 } + } + } + ) + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..fb456c4 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt @@ -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") + } + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt new file mode 100644 index 0000000..ad2c270 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt @@ -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") + } + } + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt new file mode 100644 index 0000000..795e658 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt @@ -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 = _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 +) diff --git a/app/src/main/java/com/swoosh/microblog/ui/theme/Theme.kt b/app/src/main/java/com/swoosh/microblog/ui/theme/Theme.kt new file mode 100644 index 0000000..4d97b55 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt new file mode 100644 index 0000000..f235c49 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt @@ -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() + .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( + 15, TimeUnit.MINUTES + ) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..96677be --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..c85af78 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8de324b --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Swoosh + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..657e681 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +