mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
Compare commits
49 commits
v0.2.0
...
claude/gho
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb4ad7953 | ||
|
|
84408075b7 | ||
|
|
d079fc9ba8 | ||
|
|
289082cb74 | ||
|
|
0718a9e744 | ||
|
|
da8a90470d | ||
|
|
29927a7638 | ||
|
|
e71d15805c | ||
|
|
5c931b138c | ||
|
|
0c43dc173c | ||
|
|
f93a21e743 | ||
|
|
f9d060ed7d | ||
|
|
3b1061694d | ||
|
|
a1aae661c9 | ||
|
|
39a51e5d4b | ||
|
|
c55881e7a8 | ||
|
|
27782893dc | ||
|
|
74dac1db6f | ||
|
|
bbe991b027 | ||
|
|
96e2799787 | ||
|
|
ed11577be1 | ||
|
|
2f9b7dac09 | ||
|
|
2410d05bd6 | ||
|
|
807c6d559e | ||
|
|
7d199e9fe9 | ||
|
|
a81a65281f | ||
|
|
33647d41d6 | ||
|
|
0752238578 | ||
|
|
aaf29f1512 | ||
|
|
ac461c3e6f | ||
|
|
afa0005a47 | ||
|
|
0679b18b8e | ||
|
|
11b20fd42a | ||
|
|
b829ff5963 | ||
|
|
471fea6183 | ||
|
|
83b779155e | ||
|
|
e99d88e10a | ||
|
|
be37f6284f | ||
|
|
532e04e571 | ||
|
|
64a573a95c | ||
|
|
a558a2f289 | ||
|
|
d83309f8bc | ||
|
|
492ee1ca11 | ||
|
|
689b8cc8c2 | ||
|
|
2dbb4ad005 | ||
|
|
d0019947f8 | ||
|
|
6761eae351 | ||
|
|
8326d06861 | ||
|
|
0891013df6 |
63 changed files with 9478 additions and 367 deletions
29
CLAUDE.md
29
CLAUDE.md
|
|
@ -27,9 +27,24 @@ MVVM with Repository pattern, single-module Gradle project.
|
|||
|
||||
- **`data/api/`** — Retrofit service (`GhostApiService`), JWT auth (`GhostJwtGenerator`, `GhostAuthInterceptor`), and `ApiClient` singleton with dynamic base URL
|
||||
- **`data/db/`** — Room database with `LocalPost` entity and `LocalPostDao`
|
||||
- **`data/model/`** — Three model layers: `GhostPost` (API), `LocalPost` (Room entity), `FeedPost` (UI display). Enums: `PostStatus`, `QueueStatus`
|
||||
- **`data/model/`** — Three model layers: `GhostPost` (API), `LocalPost` (Room entity), `FeedPost` (UI display). Additional models: `PostStats`, `OverallStats`, `GhostAccount`, `GhostNewsletter`, `GhostPage`, `GhostMember`. Enums: `PostStatus`, `QueueStatus`, `PostFilter`, `SortOrder`
|
||||
- **`data/repository/`** — `PostRepository` coordinates local DB and remote API; `OpenGraphFetcher` parses link previews via Jsoup
|
||||
- **`ui/`** — Jetpack Compose screens (Feed, Composer, Detail, Setup, Settings) with ViewModels using `StateFlow`
|
||||
- **`data/`** (root utilities) — `AccountManager` (multi-account, up to 5), `CredentialsManager`, `FeedPreferences`, `HashtagParser`, `MobiledocBuilder`, `ShareUtils`, `UrlNormalizer`
|
||||
- **`ui/animation/`** — `SwooshMotion` shared animation specs (bouncy, snappy, gentle, quick)
|
||||
- **`ui/components/`** — Reusable composables: `AnimatedDialog`, `ConfirmationDialog`, `PulsingPlaceholder`
|
||||
- **`ui/feed/`** — Post feed with search, filtering (All/Published/Draft/Scheduled), sorting
|
||||
- **`ui/composer/`** — Post creation/editing with image uploads, link previews, hashtags, scheduling
|
||||
- **`ui/detail/`** — Full post view with pin toggle and animated delete dialog
|
||||
- **`ui/members/`** — Ghost members/subscribers management
|
||||
- **`ui/newsletter/`** — Newsletter configuration, subscriber count, newsletter list
|
||||
- **`ui/pages/`** — Static pages CRUD (create, edit, delete, publish)
|
||||
- **`ui/preview/`** — HTML post preview
|
||||
- **`ui/stats/`** — Statistics dashboard with animated counters (total posts, word counts, reading time, tags)
|
||||
- **`ui/settings/`** — Settings, account management, theme toggle, tags toggle, disconnect
|
||||
- **`ui/setup/`** — Initial configuration wizard and add-account flow
|
||||
- **`ui/tags/`** — Tag management and filtering
|
||||
- **`ui/theme/`** — Material 3 theming with `ThemeMode` (Light/Dark/System), `ThemeViewModel`, `ThemePreferences`
|
||||
- **`ui/navigation/`** — Compose Navigation graph with bottom nav (Home, Newsletter, Stats, Settings)
|
||||
- **`worker/`** — `PostUploadWorker` (WorkManager) handles offline queue with exponential backoff
|
||||
|
||||
**Key data flow:** Posts are saved to Room first → queued for upload → `PostUploadWorker` syncs to Ghost API when network is available.
|
||||
|
|
@ -40,6 +55,10 @@ MVVM with Repository pattern, single-module Gradle project.
|
|||
- **Content format:** Posts use Ghost's mobiledoc JSON format, built by `MobiledocBuilder` (supports text paragraphs and bookmark cards)
|
||||
- **Credentials:** Stored in `EncryptedSharedPreferences` (AES256-GCM) via `CredentialsManager`
|
||||
- **API client:** Base URL is configured at runtime during setup; `ApiClient` rebuilds Retrofit instance when URL changes
|
||||
- **Multi-account:** `AccountManager` supports up to 5 Ghost accounts with UUID-based IDs, per-account credentials, and migration from legacy single-account format
|
||||
- **Avatars:** Fetched from Ghost post authors (`posts[0].authors[0].profile_image`), not `/users/me/` (which returns 404 for integrations)
|
||||
- **Permissions:** INTERNET, ACCESS_NETWORK_STATE, CAMERA
|
||||
- **ProGuard:** Release build suppresses missing `com.google.errorprone.annotations` (pulled in by Google Tink via EncryptedSharedPreferences)
|
||||
- **Min SDK 26, Target/Compile SDK 34, Kotlin 1.9.22, Java 17**
|
||||
|
||||
## Versioning
|
||||
|
|
@ -55,6 +74,10 @@ Version is defined in `app/build.gradle.kts` (`versionCode` and `versionName`).
|
|||
- **MINOR** (0.2.0 → 0.3.0): New features, UI changes, significant improvements
|
||||
- **MAJOR** (0.x → 1.0): First stable public release
|
||||
|
||||
**Current:** `versionName = "0.2.0"`, `versionCode = 2`
|
||||
**Current:** `versionName = "0.3.0"`, `versionCode = 3`
|
||||
|
||||
**Process:** When making a release commit, bump both `versionCode` (+1) and `versionName` in `app/build.gradle.kts`. Always bump version when creating a release build or PR.
|
||||
|
||||
## Assets
|
||||
|
||||
Screenshots for README are in `pics/` — named by screen (feed.png, stats.png, newsletter.png, settings.png).
|
||||
|
|
|
|||
55
README.md
55
README.md
|
|
@ -7,21 +7,35 @@ A native Android microblogging client for [Ghost CMS](https://ghost.org). Write,
|
|||
[](https://developer.android.com/jetpack/compose)
|
||||
[](LICENSE)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="pics/feed.png" width="200" alt="Feed" />
|
||||
<img src="pics/newsletter.png" width="200" alt="Newsletter" />
|
||||
<img src="pics/stats.png" width="200" alt="Statistics" />
|
||||
<img src="pics/settings.png" width="200" alt="Settings" />
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- **Ghost Admin API** — Full integration via JWT authentication (HS256)
|
||||
- **Offline-first** — Posts are saved locally and synced when connectivity returns
|
||||
- **Multi-account** — Connect up to 5 Ghost blogs and switch between them
|
||||
- **Image uploads** — Attach photos from your gallery or camera
|
||||
- **Link previews** — Automatic Open Graph metadata extraction (title, description, thumbnail)
|
||||
- **Hashtag support** — Auto-extracted hashtags converted to Ghost tags
|
||||
- **Scheduled publishing** — Set a future publish date for your posts
|
||||
- **Pin posts** — Feature/pin important posts to the top of your blog
|
||||
- **Newsletter integration** — Send posts as newsletters to your Ghost subscribers
|
||||
- **Members management** — View and manage your Ghost blog members
|
||||
- **Static pages** — Create, edit, and manage Ghost pages from the app
|
||||
- **Tag management** — Browse, create, and manage tags with per-tag post counts
|
||||
- **Statistics dashboard** — Post counts, word counts, reading time, tag breakdown, and more
|
||||
- **Search & filtering** — Filter by status (Published, Draft, Scheduled) and sort by date
|
||||
- **Mobiledoc format** — Native Ghost content format with text paragraphs and bookmark cards
|
||||
- **Encrypted credentials** — API keys stored with AES-256-GCM via AndroidX Security
|
||||
- **Background sync** — WorkManager handles upload queue with exponential backoff
|
||||
- **Material 3 UI** — Clean, modern interface built entirely with Jetpack Compose
|
||||
|
||||
## Screenshots
|
||||
|
||||
> Coming soon — contributions welcome!
|
||||
- **Material 3 UI** — Clean, green-tinted design with polished animations and Light/Dark/System themes
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -32,21 +46,31 @@ com.swoosh.microblog/
|
|||
├── data/
|
||||
│ ├── api/ # Retrofit client, JWT auth, interceptors
|
||||
│ ├── db/ # Room database, DAOs, type converters
|
||||
│ ├── model/ # GhostPost (API), LocalPost (DB), FeedPost (UI)
|
||||
│ ├── model/ # GhostPost (API), LocalPost (DB), FeedPost (UI), PostStats, GhostAccount
|
||||
│ └── repository/ # PostRepository, OpenGraphFetcher
|
||||
├── ui/
|
||||
│ ├── feed/ # Post list with pull-to-refresh
|
||||
│ ├── composer/ # Post creation and editing
|
||||
│ ├── detail/ # Full post view
|
||||
│ ├── setup/ # Initial configuration wizard
|
||||
│ ├── settings/ # App settings and logout
|
||||
│ ├── navigation/ # Compose Navigation graph
|
||||
│ └── theme/ # Material 3 theming
|
||||
│ ├── animation/ # SwooshMotion shared animation specs
|
||||
│ ├── components/ # Reusable composables (dialogs, placeholders)
|
||||
│ ├── composer/ # Post creation with images, links, hashtags, scheduling
|
||||
│ ├── detail/ # Full post view with pin toggle
|
||||
│ ├── feed/ # Post feed with search and filtering
|
||||
│ ├── members/ # Ghost members/subscribers management
|
||||
│ ├── newsletter/ # Newsletter configuration and subscriber info
|
||||
│ ├── pages/ # Static pages CRUD
|
||||
│ ├── preview/ # HTML post preview
|
||||
│ ├── settings/ # App settings, account management, theme toggle
|
||||
│ ├── setup/ # Configuration wizard and add-account flow
|
||||
│ ├── stats/ # Statistics dashboard with animated counters
|
||||
│ ├── tags/ # Tag management
|
||||
│ ├── navigation/ # Compose Navigation with bottom nav bar
|
||||
│ └── theme/ # Material 3 theming (Light/Dark/System)
|
||||
└── worker/ # PostUploadWorker (WorkManager)
|
||||
```
|
||||
|
||||
**Data flow:** Compose UI → ViewModel → Repository → Room (local) + Retrofit (remote). Posts are persisted to Room first, then queued for upload via WorkManager.
|
||||
|
||||
**Navigation:** Bottom bar with four tabs — Home (Feed), Newsletter, Stats, and Settings. Composer, Detail, Pages, Members, and Tags screens slide in as overlays.
|
||||
|
||||
## Getting started
|
||||
|
||||
### Prerequisites
|
||||
|
|
@ -66,7 +90,7 @@ com.swoosh.microblog/
|
|||
### Build and run
|
||||
|
||||
```bash
|
||||
git clone https://github.com/pawelorzech/Swoosh.git
|
||||
git clone https://github.com/nicekid1/Swoosh.git
|
||||
cd Swoosh
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
|
@ -89,7 +113,7 @@ The project includes unit tests with JUnit 4 and Robolectric:
|
|||
./gradlew app:testDebugUnitTest # Debug variant only
|
||||
```
|
||||
|
||||
Test coverage includes JWT generation, mobiledoc building, URL normalization, data model serialization, auth interceptors, and time formatting.
|
||||
31 test classes covering JWT generation, mobiledoc building, URL normalization, data model serialization, auth interceptors, time formatting, hashtag parsing, account management, feed preferences, newsletter preferences, member models, and theme modes.
|
||||
|
||||
## Tech stack
|
||||
|
||||
|
|
@ -100,6 +124,7 @@ Test coverage includes JWT generation, mobiledoc building, URL normalization, da
|
|||
| Architecture | MVVM, StateFlow, Repository pattern |
|
||||
| Networking | Retrofit 2, OkHttp 4 |
|
||||
| Images | Coil |
|
||||
| Media | Media3 ExoPlayer |
|
||||
| Database | Room |
|
||||
| Background | WorkManager |
|
||||
| Auth | JJWT (HS256), EncryptedSharedPreferences |
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ android {
|
|||
applicationId = "com.swoosh.microblog"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 2
|
||||
versionName = "0.2.0"
|
||||
versionCode = 3
|
||||
versionName = "0.3.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables { useSupportLibrary = true }
|
||||
|
|
@ -107,6 +107,10 @@ dependencies {
|
|||
// Jsoup for OpenGraph parsing
|
||||
implementation("org.jsoup:jsoup:1.17.2")
|
||||
|
||||
// Media3 for video/audio playback
|
||||
implementation("androidx.media3:media3-exoplayer:1.2.1")
|
||||
implementation("androidx.media3:media3-ui:1.2.1")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
|
|
|
|||
|
|
@ -9,59 +9,27 @@ import com.swoosh.microblog.data.model.LinkPreview
|
|||
object MobiledocBuilder {
|
||||
|
||||
fun build(text: String, linkPreview: LinkPreview?): String {
|
||||
return build(text, linkPreview?.url, linkPreview?.title, linkPreview?.description)
|
||||
}
|
||||
|
||||
fun build(
|
||||
text: String,
|
||||
linkUrl: String?,
|
||||
linkTitle: String?,
|
||||
linkDescription: String?
|
||||
): String {
|
||||
return build(text, emptyList(), linkUrl, linkTitle, linkDescription, null)
|
||||
return build(text, linkUrl = linkPreview?.url, linkTitle = linkPreview?.title, linkDescription = linkPreview?.description)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build with a single image URL and optional alt text (HEAD's 6-param overload).
|
||||
* Builds mobiledoc JSON with support for multiple images (with optional alt text on the first),
|
||||
* optional video, optional audio, an optional link preview, and an optional file attachment.
|
||||
*
|
||||
* Card order: images -> video -> audio -> bookmark -> file
|
||||
*/
|
||||
fun build(
|
||||
text: String,
|
||||
linkUrl: String?,
|
||||
linkTitle: String?,
|
||||
linkDescription: String?,
|
||||
imageUrl: String?,
|
||||
imageAlt: String?
|
||||
): String {
|
||||
val imageUrls = if (imageUrl != null) listOf(imageUrl) else emptyList()
|
||||
return build(text, imageUrls, linkUrl, linkTitle, linkDescription, imageAlt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build with multiple image URLs but no alt text (multi-image branch's 5-param overload).
|
||||
*/
|
||||
fun build(
|
||||
text: String,
|
||||
imageUrls: List<String>,
|
||||
linkUrl: String?,
|
||||
linkTitle: String?,
|
||||
linkDescription: String?
|
||||
): String {
|
||||
return build(text, imageUrls, linkUrl, linkTitle, linkDescription, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds mobiledoc JSON with support for multiple images (with optional alt text on the first)
|
||||
* and an optional link preview.
|
||||
* Each image becomes an image card in the mobiledoc format.
|
||||
* The bookmark card (link preview) is added after image cards.
|
||||
*/
|
||||
fun build(
|
||||
text: String,
|
||||
imageUrls: List<String>,
|
||||
linkUrl: String?,
|
||||
linkTitle: String?,
|
||||
linkDescription: String?,
|
||||
imageAlt: String?
|
||||
imageUrls: List<String> = emptyList(),
|
||||
linkUrl: String? = null,
|
||||
linkTitle: String? = null,
|
||||
linkDescription: String? = null,
|
||||
imageAlt: String? = null,
|
||||
videoUrl: String? = null,
|
||||
audioUrl: String? = null,
|
||||
fileUrl: String? = null,
|
||||
fileName: String? = null,
|
||||
fileSize: Long = 0
|
||||
): String {
|
||||
val escapedText = escapeForJson(text).replace("\n", "\\n")
|
||||
|
||||
|
|
@ -77,6 +45,20 @@ object MobiledocBuilder {
|
|||
cardSections.add("[10,${cards.size - 1}]")
|
||||
}
|
||||
|
||||
// Add video card if present
|
||||
if (videoUrl != null) {
|
||||
val escapedUrl = escapeForJson(videoUrl)
|
||||
cards.add("""["video",{"src":"$escapedUrl","loop":false}]""")
|
||||
cardSections.add("[10,${cards.size - 1}]")
|
||||
}
|
||||
|
||||
// Add audio card if present
|
||||
if (audioUrl != null) {
|
||||
val escapedUrl = escapeForJson(audioUrl)
|
||||
cards.add("""["audio",{"src":"$escapedUrl"}]""")
|
||||
cardSections.add("[10,${cards.size - 1}]")
|
||||
}
|
||||
|
||||
// Add bookmark card if link is present
|
||||
if (linkUrl != null) {
|
||||
val escapedUrl = escapeForJson(linkUrl)
|
||||
|
|
@ -86,6 +68,14 @@ object MobiledocBuilder {
|
|||
cardSections.add("[10,${cards.size - 1}]")
|
||||
}
|
||||
|
||||
// Add file card if file is present
|
||||
if (fileUrl != null) {
|
||||
val escapedFileUrl = escapeForJson(fileUrl)
|
||||
val escapedFileName = fileName?.let { escapeForJson(it) } ?: ""
|
||||
cards.add("""["file",{"src":"$escapedFileUrl","fileName":"$escapedFileName","fileSize":$fileSize}]""")
|
||||
cardSections.add("[10,${cards.size - 1}]")
|
||||
}
|
||||
|
||||
val cardsJson = cards.joinToString(",")
|
||||
val cardSectionsJson = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else ""
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
package com.swoosh.microblog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
class NewsletterPreferences private constructor(
|
||||
private val prefs: SharedPreferences,
|
||||
private val accountIdProvider: () -> String
|
||||
) {
|
||||
|
||||
constructor(context: Context) : this(
|
||||
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE),
|
||||
accountIdProvider = { AccountManager(context).getActiveAccount()?.id ?: "" }
|
||||
)
|
||||
|
||||
/** Constructor for testing with plain SharedPreferences and a fixed account ID. */
|
||||
constructor(prefs: SharedPreferences, accountId: String) : this(
|
||||
prefs = prefs,
|
||||
accountIdProvider = { accountId }
|
||||
)
|
||||
|
||||
private fun activeAccountId(): String = accountIdProvider()
|
||||
|
||||
fun isNewsletterEnabled(): Boolean =
|
||||
prefs.getBoolean("newsletter_enabled_${activeAccountId()}", false)
|
||||
|
||||
fun setNewsletterEnabled(enabled: Boolean) =
|
||||
prefs.edit().putBoolean("newsletter_enabled_${activeAccountId()}", enabled).apply()
|
||||
|
||||
companion object {
|
||||
const val PREFS_NAME = "newsletter_prefs"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.swoosh.microblog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.Gson
|
||||
import com.swoosh.microblog.data.model.GhostSite
|
||||
|
||||
class SiteMetadataCache(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(
|
||||
PREFS_NAME, Context.MODE_PRIVATE
|
||||
)
|
||||
private val gson = Gson()
|
||||
|
||||
fun save(accountId: String, site: GhostSite) {
|
||||
val json = gson.toJson(site)
|
||||
prefs.edit().putString(keyForAccount(accountId), json).apply()
|
||||
}
|
||||
|
||||
fun get(accountId: String): GhostSite? {
|
||||
val json = prefs.getString(keyForAccount(accountId), null) ?: return null
|
||||
return try {
|
||||
gson.fromJson(json, GhostSite::class.java)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getVersion(accountId: String): String? {
|
||||
return get(accountId)?.version
|
||||
}
|
||||
|
||||
fun remove(accountId: String) {
|
||||
prefs.edit().remove(keyForAccount(accountId)).apply()
|
||||
}
|
||||
|
||||
private fun keyForAccount(accountId: String): String = "site_$accountId"
|
||||
|
||||
companion object {
|
||||
const val PREFS_NAME = "site_metadata_cache"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.swoosh.microblog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
class TagsPreferences private constructor(
|
||||
private val prefs: SharedPreferences,
|
||||
private val accountIdProvider: () -> String
|
||||
) {
|
||||
|
||||
constructor(context: Context) : this(
|
||||
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE),
|
||||
accountIdProvider = { AccountManager(context).getActiveAccount()?.id ?: "" }
|
||||
)
|
||||
|
||||
constructor(prefs: SharedPreferences, accountId: String) : this(
|
||||
prefs = prefs,
|
||||
accountIdProvider = { accountId }
|
||||
)
|
||||
|
||||
private fun activeAccountId(): String = accountIdProvider()
|
||||
|
||||
fun isTagsEnabled(): Boolean =
|
||||
prefs.getBoolean("tags_enabled_${activeAccountId()}", true)
|
||||
|
||||
fun setTagsEnabled(enabled: Boolean) =
|
||||
prefs.edit().putBoolean("tags_enabled_${activeAccountId()}", enabled).apply()
|
||||
|
||||
companion object {
|
||||
const val PREFS_NAME = "tags_prefs"
|
||||
}
|
||||
}
|
||||
|
|
@ -16,3 +16,10 @@ object UrlNormalizer {
|
|||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the URL scheme (http/https) and trailing slash for display purposes.
|
||||
* e.g., "https://example.com/" -> "example.com"
|
||||
*/
|
||||
fun String.toDisplayUrl(): String =
|
||||
removePrefix("https://").removePrefix("http://").removeSuffix("/")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
package com.swoosh.microblog.data.api
|
||||
|
||||
import com.swoosh.microblog.data.model.FileUploadResponse
|
||||
import com.swoosh.microblog.data.model.GhostSite
|
||||
import com.swoosh.microblog.data.model.MediaUploadResponse
|
||||
import com.swoosh.microblog.data.model.MembersResponse
|
||||
import com.swoosh.microblog.data.model.NewslettersResponse
|
||||
import com.swoosh.microblog.data.model.PageWrapper
|
||||
import com.swoosh.microblog.data.model.PagesResponse
|
||||
import com.swoosh.microblog.data.model.PostWrapper
|
||||
import com.swoosh.microblog.data.model.PostsResponse
|
||||
import com.swoosh.microblog.data.model.TagsResponse
|
||||
import com.swoosh.microblog.data.model.TagWrapper
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.Response
|
||||
|
|
@ -22,14 +31,18 @@ interface GhostApiService {
|
|||
@POST("ghost/api/admin/posts/")
|
||||
@Headers("Content-Type: application/json")
|
||||
suspend fun createPost(
|
||||
@Body body: PostWrapper
|
||||
@Body body: PostWrapper,
|
||||
@Query("newsletter") newsletter: String? = null,
|
||||
@Query("email_segment") emailSegment: String? = null
|
||||
): Response<PostsResponse>
|
||||
|
||||
@PUT("ghost/api/admin/posts/{id}/")
|
||||
@Headers("Content-Type: application/json")
|
||||
suspend fun updatePost(
|
||||
@Path("id") id: String,
|
||||
@Body body: PostWrapper
|
||||
@Body body: PostWrapper,
|
||||
@Query("newsletter") newsletter: String? = null,
|
||||
@Query("email_segment") emailSegment: String? = null
|
||||
): Response<PostsResponse>
|
||||
|
||||
@DELETE("ghost/api/admin/posts/{id}/")
|
||||
|
|
@ -37,15 +50,104 @@ interface GhostApiService {
|
|||
@Path("id") id: String
|
||||
): Response<Unit>
|
||||
|
||||
@GET("ghost/api/admin/site/")
|
||||
suspend fun getSite(): Response<GhostSite>
|
||||
|
||||
@GET("ghost/api/admin/members/")
|
||||
suspend fun getMembers(
|
||||
@Query("limit") limit: Int = 15,
|
||||
@Query("page") page: Int = 1,
|
||||
@Query("order") order: String = "created_at desc",
|
||||
@Query("filter") filter: String? = null,
|
||||
@Query("include") include: String = "newsletters,labels"
|
||||
): Response<MembersResponse>
|
||||
|
||||
@GET("ghost/api/admin/members/{id}/")
|
||||
suspend fun getMember(
|
||||
@Path("id") id: String,
|
||||
@Query("include") include: String = "newsletters,labels"
|
||||
): Response<MembersResponse>
|
||||
|
||||
@GET("ghost/api/admin/newsletters/")
|
||||
suspend fun getNewsletters(
|
||||
@Query("filter") filter: String = "status:active",
|
||||
@Query("limit") limit: String = "all"
|
||||
): Response<NewslettersResponse>
|
||||
|
||||
@GET("ghost/api/admin/users/me/")
|
||||
suspend fun getCurrentUser(): Response<UsersResponse>
|
||||
|
||||
// --- Pages ---
|
||||
|
||||
@GET("ghost/api/admin/pages/")
|
||||
suspend fun getPages(
|
||||
@Query("limit") limit: String = "all",
|
||||
@Query("formats") formats: String = "html,plaintext,mobiledoc"
|
||||
): Response<PagesResponse>
|
||||
|
||||
@POST("ghost/api/admin/pages/")
|
||||
@Headers("Content-Type: application/json")
|
||||
suspend fun createPage(@Body body: PageWrapper): Response<PagesResponse>
|
||||
|
||||
@PUT("ghost/api/admin/pages/{id}/")
|
||||
@Headers("Content-Type: application/json")
|
||||
suspend fun updatePage(
|
||||
@Path("id") id: String,
|
||||
@Body body: PageWrapper
|
||||
): Response<PagesResponse>
|
||||
|
||||
@DELETE("ghost/api/admin/pages/{id}/")
|
||||
suspend fun deletePage(@Path("id") id: String): Response<Unit>
|
||||
|
||||
// --- Tags ---
|
||||
|
||||
@GET("ghost/api/admin/tags/")
|
||||
suspend fun getTags(
|
||||
@Query("limit") limit: String = "all",
|
||||
@Query("include") include: String = "count.posts"
|
||||
): Response<TagsResponse>
|
||||
|
||||
@GET("ghost/api/admin/tags/{id}/")
|
||||
suspend fun getTag(@Path("id") id: String): Response<TagsResponse>
|
||||
|
||||
@POST("ghost/api/admin/tags/")
|
||||
@Headers("Content-Type: application/json")
|
||||
suspend fun createTag(@Body body: TagWrapper): Response<TagsResponse>
|
||||
|
||||
@PUT("ghost/api/admin/tags/{id}/")
|
||||
@Headers("Content-Type: application/json")
|
||||
suspend fun updateTag(@Path("id") id: String, @Body body: TagWrapper): Response<TagsResponse>
|
||||
|
||||
@DELETE("ghost/api/admin/tags/{id}/")
|
||||
suspend fun deleteTag(@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>
|
||||
|
||||
@Multipart
|
||||
@POST("ghost/api/admin/files/upload/")
|
||||
suspend fun uploadFile(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("ref") ref: RequestBody? = null
|
||||
): Response<FileUploadResponse>
|
||||
|
||||
@Multipart
|
||||
@POST("ghost/api/admin/media/upload/")
|
||||
suspend fun uploadMedia(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("ref") ref: RequestBody? = null
|
||||
): Response<MediaUploadResponse>
|
||||
|
||||
@Multipart
|
||||
@POST("ghost/api/admin/media/thumbnail/upload/")
|
||||
suspend fun uploadMediaThumbnail(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("ref") ref: RequestBody? = null
|
||||
): Response<MediaUploadResponse>
|
||||
}
|
||||
|
||||
data class ImageUploadResponse(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import androidx.room.migration.Migration
|
|||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.swoosh.microblog.data.model.LocalPost
|
||||
|
||||
@Database(entities = [LocalPost::class], version = 3, exportSchema = false)
|
||||
@Database(entities = [LocalPost::class], version = 4, exportSchema = false)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
|
|
@ -42,6 +42,25 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
override fun migrate(db: SupportSQLiteDatabase) = addColumnsIfMissing(db)
|
||||
}
|
||||
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
val columns = listOf(
|
||||
"ALTER TABLE local_posts ADD COLUMN emailOnly INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE local_posts ADD COLUMN newsletterSlug TEXT DEFAULT NULL",
|
||||
"ALTER TABLE local_posts ADD COLUMN videoUri TEXT DEFAULT NULL",
|
||||
"ALTER TABLE local_posts ADD COLUMN uploadedVideoUrl TEXT DEFAULT NULL",
|
||||
"ALTER TABLE local_posts ADD COLUMN audioUri TEXT DEFAULT NULL",
|
||||
"ALTER TABLE local_posts ADD COLUMN uploadedAudioUrl TEXT DEFAULT NULL",
|
||||
"ALTER TABLE local_posts ADD COLUMN fileUri TEXT DEFAULT NULL",
|
||||
"ALTER TABLE local_posts ADD COLUMN uploadedFileUrl TEXT DEFAULT NULL",
|
||||
"ALTER TABLE local_posts ADD COLUMN fileName TEXT DEFAULT NULL"
|
||||
)
|
||||
for (sql in columns) {
|
||||
try { db.execSQL(sql) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
|
|
@ -49,7 +68,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
AppDatabase::class.java,
|
||||
"swoosh_database"
|
||||
)
|
||||
.addMigrations(MIGRATION_1_3, MIGRATION_2_3)
|
||||
.addMigrations(MIGRATION_1_3, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
|
|
|
|||
|
|
@ -11,13 +11,21 @@ class Converters {
|
|||
fun fromPostStatus(value: PostStatus): String = value.name
|
||||
|
||||
@TypeConverter
|
||||
fun toPostStatus(value: String): PostStatus = PostStatus.valueOf(value)
|
||||
fun toPostStatus(value: String): PostStatus = try {
|
||||
PostStatus.valueOf(value)
|
||||
} catch (_: Exception) {
|
||||
PostStatus.DRAFT
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromQueueStatus(value: QueueStatus): String = value.name
|
||||
|
||||
@TypeConverter
|
||||
fun toQueueStatus(value: String): QueueStatus = QueueStatus.valueOf(value)
|
||||
fun toQueueStatus(value: String): QueueStatus = try {
|
||||
QueueStatus.valueOf(value)
|
||||
} catch (_: Exception) {
|
||||
QueueStatus.NONE
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val gson = Gson()
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@ interface LocalPostDao {
|
|||
|
||||
@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)
|
||||
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED, QueueStatus.QUEUED_EMAIL_ONLY)
|
||||
): List<LocalPost>
|
||||
|
||||
@Query("SELECT * FROM local_posts WHERE accountId = :accountId AND queueStatus IN (:statuses) ORDER BY createdAt ASC")
|
||||
suspend fun getQueuedPostsByAccount(
|
||||
accountId: String,
|
||||
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
|
||||
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED, QueueStatus.QUEUED_EMAIL_ONLY)
|
||||
): List<LocalPost>
|
||||
|
||||
@Query("SELECT * FROM local_posts WHERE localId = :localId")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
data class FileUploadResponse(
|
||||
val files: List<UploadedFile>
|
||||
)
|
||||
|
||||
data class UploadedFile(
|
||||
val url: String,
|
||||
val ref: String?
|
||||
)
|
||||
|
|
@ -48,7 +48,8 @@ data class GhostPost(
|
|||
val visibility: String? = "public",
|
||||
val authors: List<Author>? = null,
|
||||
val reading_time: Int? = null,
|
||||
val tags: List<GhostTag>? = null
|
||||
val tags: List<GhostTag>? = null,
|
||||
val email_only: Boolean? = null
|
||||
)
|
||||
|
||||
data class GhostTag(
|
||||
|
|
@ -89,19 +90,33 @@ data class LocalPost(
|
|||
val tags: String = "[]",
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
val queueStatus: QueueStatus = QueueStatus.NONE
|
||||
val queueStatus: QueueStatus = QueueStatus.NONE,
|
||||
// Phase 4b: email-only
|
||||
val emailOnly: Boolean = false,
|
||||
val newsletterSlug: String? = null,
|
||||
// Phase 5: media
|
||||
val videoUri: String? = null,
|
||||
val uploadedVideoUrl: String? = null,
|
||||
val audioUri: String? = null,
|
||||
val uploadedAudioUrl: String? = null,
|
||||
// Phase 6: file
|
||||
val fileUri: String? = null,
|
||||
val uploadedFileUrl: String? = null,
|
||||
val fileName: String? = null
|
||||
)
|
||||
|
||||
enum class PostStatus {
|
||||
DRAFT,
|
||||
PUBLISHED,
|
||||
SCHEDULED
|
||||
SCHEDULED,
|
||||
SENT
|
||||
}
|
||||
|
||||
enum class QueueStatus {
|
||||
NONE,
|
||||
QUEUED_PUBLISH,
|
||||
QUEUED_SCHEDULED,
|
||||
QUEUED_EMAIL_ONLY,
|
||||
UPLOADING,
|
||||
FAILED
|
||||
}
|
||||
|
|
@ -120,6 +135,8 @@ data class FeedPost(
|
|||
val imageUrl: String?,
|
||||
val imageAlt: String? = null,
|
||||
val imageUrls: List<String> = emptyList(),
|
||||
val videoUrl: String? = null,
|
||||
val audioUrl: String? = null,
|
||||
val linkUrl: String?,
|
||||
val linkTitle: String?,
|
||||
val linkDescription: String?,
|
||||
|
|
@ -131,7 +148,10 @@ data class FeedPost(
|
|||
val createdAt: String?,
|
||||
val updatedAt: String?,
|
||||
val isLocal: Boolean = false,
|
||||
val queueStatus: QueueStatus = QueueStatus.NONE
|
||||
val queueStatus: QueueStatus = QueueStatus.NONE,
|
||||
val fileUrl: String? = null,
|
||||
val fileName: String? = null,
|
||||
val emailOnly: Boolean = false
|
||||
)
|
||||
|
||||
@Stable
|
||||
|
|
@ -148,7 +168,8 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
|
|||
ALL("All", null),
|
||||
PUBLISHED("Published", "status:published"),
|
||||
DRAFT("Drafts", "status:draft"),
|
||||
SCHEDULED("Scheduled", "status:scheduled");
|
||||
SCHEDULED("Scheduled", "status:scheduled"),
|
||||
SENT("Sent", "status:sent");
|
||||
|
||||
/** Returns the matching [PostStatus] for local filtering, or null for ALL. */
|
||||
fun toPostStatus(): PostStatus? = when (this) {
|
||||
|
|
@ -156,6 +177,7 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
|
|||
PUBLISHED -> PostStatus.PUBLISHED
|
||||
DRAFT -> PostStatus.DRAFT
|
||||
SCHEDULED -> PostStatus.SCHEDULED
|
||||
SENT -> PostStatus.SENT
|
||||
}
|
||||
|
||||
/** Empty-state message shown when filter yields no results. */
|
||||
|
|
@ -164,6 +186,7 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
|
|||
PUBLISHED -> "No published posts yet"
|
||||
DRAFT -> "No drafts yet"
|
||||
SCHEDULED -> "No scheduled posts yet"
|
||||
SENT -> "No sent newsletters yet"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
data class MediaUploadResponse(
|
||||
val media: List<UploadedMedia>
|
||||
)
|
||||
|
||||
data class UploadedMedia(
|
||||
val url: String,
|
||||
val ref: String?,
|
||||
val fileName: String?
|
||||
)
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
data class MembersResponse(
|
||||
val members: List<GhostMember>,
|
||||
val meta: Meta?
|
||||
)
|
||||
|
||||
data class GhostMember(
|
||||
val id: String,
|
||||
val email: String?,
|
||||
val name: String?,
|
||||
val status: String?, // "free" or "paid"
|
||||
val avatar_image: String?,
|
||||
val email_count: Int?,
|
||||
val email_opened_count: Int?,
|
||||
val email_open_rate: Double?,
|
||||
val last_seen_at: String?,
|
||||
val created_at: String?,
|
||||
val updated_at: String?,
|
||||
val labels: List<MemberLabel>?,
|
||||
val newsletters: List<MemberNewsletter>?,
|
||||
val subscriptions: List<MemberSubscription>?,
|
||||
val note: String?,
|
||||
val geolocation: String?
|
||||
)
|
||||
|
||||
data class MemberLabel(val id: String?, val name: String, val slug: String?)
|
||||
data class MemberNewsletter(val id: String, val name: String?, val slug: String?)
|
||||
data class MemberSubscription(
|
||||
val id: String?, val status: String?, val start_date: String?,
|
||||
val current_period_end: String?, val cancel_at_period_end: Boolean?,
|
||||
val price: SubscriptionPrice?, val tier: SubscriptionTier?
|
||||
)
|
||||
data class SubscriptionPrice(val amount: Int?, val currency: String?, val interval: String?)
|
||||
data class SubscriptionTier(val id: String?, val name: String?)
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
data class NewslettersResponse(
|
||||
val newsletters: List<GhostNewsletter>
|
||||
)
|
||||
|
||||
data class GhostNewsletter(
|
||||
val id: String,
|
||||
val uuid: String?,
|
||||
val name: String,
|
||||
val slug: String,
|
||||
val description: String?,
|
||||
val status: String?,
|
||||
val visibility: String?,
|
||||
val subscribe_on_signup: Boolean?,
|
||||
val sort_order: Int?,
|
||||
val sender_name: String?,
|
||||
val sender_email: String?,
|
||||
val created_at: String?,
|
||||
val updated_at: String?
|
||||
)
|
||||
|
|
@ -38,6 +38,7 @@ data class OverallStats(
|
|||
PostStatus.PUBLISHED -> publishedCount++
|
||||
PostStatus.DRAFT -> draftCount++
|
||||
PostStatus.SCHEDULED -> scheduledCount++
|
||||
PostStatus.SENT -> publishedCount++ // sent counts as published
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ data class OverallStats(
|
|||
"published" -> publishedCount++
|
||||
"draft" -> draftCount++
|
||||
"scheduled" -> scheduledCount++
|
||||
"sent" -> publishedCount++
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
data class PagesResponse(
|
||||
val pages: List<GhostPage>,
|
||||
val meta: Meta?
|
||||
)
|
||||
|
||||
data class PageWrapper(
|
||||
val pages: List<GhostPage>
|
||||
)
|
||||
|
||||
data class GhostPage(
|
||||
val id: String? = null,
|
||||
val title: String? = null,
|
||||
val slug: String? = null,
|
||||
val url: String? = null,
|
||||
val html: String? = null,
|
||||
val plaintext: String? = null,
|
||||
val mobiledoc: String? = null,
|
||||
val status: String? = null,
|
||||
val feature_image: String? = null,
|
||||
val custom_excerpt: String? = null,
|
||||
val created_at: String? = null,
|
||||
val updated_at: String? = null,
|
||||
val published_at: String? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
data class GhostSite(
|
||||
val title: String?,
|
||||
val description: String?,
|
||||
val logo: String?,
|
||||
val icon: String?,
|
||||
val accent_color: String?,
|
||||
val url: String?,
|
||||
val version: String?,
|
||||
val locale: String?
|
||||
)
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
data class TagsResponse(
|
||||
val tags: List<GhostTagFull>,
|
||||
val meta: Meta?
|
||||
)
|
||||
|
||||
data class TagWrapper(
|
||||
val tags: List<GhostTagFull>
|
||||
)
|
||||
|
||||
data class GhostTagFull(
|
||||
val id: String? = null,
|
||||
val name: String,
|
||||
val slug: String? = null,
|
||||
val description: String? = null,
|
||||
val feature_image: String? = null,
|
||||
val visibility: String? = "public",
|
||||
val accent_color: String? = null,
|
||||
val count: TagCount? = null,
|
||||
val created_at: String? = null,
|
||||
val updated_at: String? = null,
|
||||
val url: String? = null
|
||||
)
|
||||
|
||||
data class TagCount(val posts: Int?)
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
package com.swoosh.microblog.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.swoosh.microblog.data.AccountManager
|
||||
import com.swoosh.microblog.data.api.ApiClient
|
||||
import com.swoosh.microblog.data.api.GhostApiService
|
||||
import com.swoosh.microblog.data.model.GhostMember
|
||||
import com.swoosh.microblog.data.model.MembersResponse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class MemberRepository(private val context: Context) {
|
||||
|
||||
private val accountManager = AccountManager(context)
|
||||
|
||||
private fun getApi(): GhostApiService {
|
||||
val account = accountManager.getActiveAccount()
|
||||
?: throw IllegalStateException("No active account configured")
|
||||
return ApiClient.getService(account.blogUrl) { account.apiKey }
|
||||
}
|
||||
|
||||
suspend fun fetchMembers(
|
||||
page: Int = 1,
|
||||
limit: Int = 15,
|
||||
filter: String? = null
|
||||
): Result<MembersResponse> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().getMembers(
|
||||
limit = limit,
|
||||
page = page,
|
||||
filter = filter
|
||||
)
|
||||
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 fetchMember(id: String): Result<GhostMember> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().getMember(id)
|
||||
if (response.isSuccessful) {
|
||||
val members = response.body()!!.members
|
||||
if (members.isNotEmpty()) {
|
||||
Result.success(members.first())
|
||||
} else {
|
||||
Result.failure(Exception("Member not found"))
|
||||
}
|
||||
} else {
|
||||
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAllMembers(): Result<List<GhostMember>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val allMembers = mutableListOf<GhostMember>()
|
||||
var page = 1
|
||||
var hasMore = true
|
||||
|
||||
while (hasMore && page <= 20) {
|
||||
val response = getApi().getMembers(limit = 50, page = page)
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()!!
|
||||
allMembers.addAll(body.members)
|
||||
hasMore = body.meta?.pagination?.next != null
|
||||
page++
|
||||
} else {
|
||||
return@withContext Result.failure(
|
||||
Exception("API error ${response.code()}: ${response.errorBody()?.string()}")
|
||||
)
|
||||
}
|
||||
}
|
||||
Result.success(allMembers)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getMemberStats(members: List<GhostMember>): MemberStats {
|
||||
val total = members.size
|
||||
val free = members.count { it.status == "free" }
|
||||
val paid = members.count { it.status == "paid" }
|
||||
|
||||
val oneWeekAgo = Instant.now().minus(7, ChronoUnit.DAYS)
|
||||
val newThisWeek = members.count { member ->
|
||||
member.created_at?.let {
|
||||
try {
|
||||
Instant.parse(it).isAfter(oneWeekAgo)
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
|
||||
val openRates = members.mapNotNull { it.email_open_rate }
|
||||
val avgOpenRate = if (openRates.isNotEmpty()) openRates.average() else null
|
||||
|
||||
// Calculate MRR from paid member subscriptions
|
||||
val mrr = members.filter { it.status == "paid" }.sumOf { member ->
|
||||
member.subscriptions?.sumOf { sub ->
|
||||
if (sub.status == "active") {
|
||||
val amount = sub.price?.amount ?: 0
|
||||
when (sub.price?.interval) {
|
||||
"year" -> amount / 12
|
||||
"month" -> amount
|
||||
else -> amount
|
||||
}
|
||||
} else 0
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
return MemberStats(
|
||||
total = total,
|
||||
free = free,
|
||||
paid = paid,
|
||||
newThisWeek = newThisWeek,
|
||||
avgOpenRate = avgOpenRate,
|
||||
mrr = mrr
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class MemberStats(
|
||||
val total: Int,
|
||||
val free: Int,
|
||||
val paid: Int,
|
||||
val newThisWeek: Int,
|
||||
val avgOpenRate: Double?,
|
||||
val mrr: Int
|
||||
)
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package com.swoosh.microblog.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.swoosh.microblog.data.AccountManager
|
||||
import com.swoosh.microblog.data.api.ApiClient
|
||||
import com.swoosh.microblog.data.api.GhostApiService
|
||||
import com.swoosh.microblog.data.model.GhostPage
|
||||
import com.swoosh.microblog.data.model.PageWrapper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class PageRepository(private val context: Context) {
|
||||
|
||||
private val accountManager = AccountManager(context)
|
||||
|
||||
private fun getApi(): GhostApiService {
|
||||
val account = accountManager.getActiveAccount()
|
||||
?: throw IllegalStateException("No active account configured")
|
||||
return ApiClient.getService(account.blogUrl) { account.apiKey }
|
||||
}
|
||||
|
||||
fun getBlogUrl(): String? {
|
||||
return accountManager.getActiveAccount()?.blogUrl
|
||||
}
|
||||
|
||||
suspend fun fetchPages(): Result<List<GhostPage>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().getPages()
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!.pages)
|
||||
} else {
|
||||
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createPage(page: GhostPage): Result<GhostPage> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().createPage(PageWrapper(listOf(page)))
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!.pages.first())
|
||||
} else {
|
||||
Result.failure(Exception("Create failed ${response.code()}: ${response.errorBody()?.string()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePage(id: String, page: GhostPage): Result<GhostPage> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().updatePage(id, PageWrapper(listOf(page)))
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!.pages.first())
|
||||
} else {
|
||||
Result.failure(Exception("Update failed ${response.code()}: ${response.errorBody()?.string()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deletePage(id: String): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().deletePage(id)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception("Delete failed ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ 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 com.swoosh.microblog.data.model.GhostNewsletter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -68,10 +69,18 @@ class PostRepository(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun createPost(post: GhostPost): Result<GhostPost> =
|
||||
suspend fun createPost(
|
||||
post: GhostPost,
|
||||
newsletter: String? = null,
|
||||
emailSegment: String? = null
|
||||
): Result<GhostPost> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().createPost(PostWrapper(listOf(post)))
|
||||
val response = getApi().createPost(
|
||||
PostWrapper(listOf(post)),
|
||||
newsletter = newsletter,
|
||||
emailSegment = emailSegment
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!.posts.first())
|
||||
} else {
|
||||
|
|
@ -82,10 +91,20 @@ class PostRepository(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun updatePost(id: String, post: GhostPost): Result<GhostPost> =
|
||||
suspend fun updatePost(
|
||||
id: String,
|
||||
post: GhostPost,
|
||||
newsletter: String? = null,
|
||||
emailSegment: String? = null
|
||||
): Result<GhostPost> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().updatePost(id, PostWrapper(listOf(post)))
|
||||
val response = getApi().updatePost(
|
||||
id,
|
||||
PostWrapper(listOf(post)),
|
||||
newsletter = newsletter,
|
||||
emailSegment = emailSegment
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!.posts.first())
|
||||
} else {
|
||||
|
|
@ -96,6 +115,35 @@ class PostRepository(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun fetchNewsletters(): Result<List<GhostNewsletter>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().getNewsletters()
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!.newsletters)
|
||||
} else {
|
||||
Result.failure(Exception("Newsletters fetch failed ${response.code()}: ${response.errorBody()?.string()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSubscriberCount(): Result<Int> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().getMembers(limit = 1)
|
||||
if (response.isSuccessful) {
|
||||
val total = response.body()!!.meta?.pagination?.total ?: 0
|
||||
Result.success(total)
|
||||
} else {
|
||||
Result.failure(Exception("Member count fetch failed ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deletePost(id: String): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
|
@ -113,8 +161,8 @@ class PostRepository(private val context: Context) {
|
|||
suspend fun uploadImage(uri: Uri): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val file = copyUriToTempFile(uri)
|
||||
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
|
||||
val file = copyUriToTempFile(uri, ".jpg")
|
||||
val requestBody = file.asRequestBody(mimeType.toMediaType())
|
||||
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
|
||||
val purpose = "image".toRequestBody("text/plain".toMediaType())
|
||||
|
|
@ -133,6 +181,28 @@ class PostRepository(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun uploadFile(uri: Uri): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val file = copyUriToTempFile(uri)
|
||||
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
val requestBody = file.asRequestBody(mimeType.toMediaType())
|
||||
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
|
||||
|
||||
val response = getApi().uploadFile(part)
|
||||
file.delete()
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val url = response.body()!!.files.first().url
|
||||
Result.success(url)
|
||||
} else {
|
||||
Result.failure(Exception("File upload failed ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads multiple images and returns all uploaded URLs.
|
||||
* If any upload fails, returns failure with the error.
|
||||
|
|
@ -156,10 +226,41 @@ class PostRepository(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun copyUriToTempFile(uri: Uri): File {
|
||||
/**
|
||||
* Uploads a media file (video or audio) to the Ghost media upload endpoint.
|
||||
* Determines MIME type from the content resolver. Returns the uploaded URL on success.
|
||||
*/
|
||||
suspend fun uploadMediaFile(uri: Uri): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
val extension = when {
|
||||
mimeType.startsWith("video/") -> mimeType.substringAfter("video/").let { ".$it" }
|
||||
mimeType.startsWith("audio/") -> mimeType.substringAfter("audio/").let { ".$it" }
|
||||
else -> ""
|
||||
}
|
||||
val file = copyUriToTempFile(uri, extension)
|
||||
val requestBody = file.asRequestBody(mimeType.toMediaType())
|
||||
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
|
||||
|
||||
val response = getApi().uploadMedia(part)
|
||||
file.delete()
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val url = response.body()!!.media.first().url
|
||||
Result.success(url)
|
||||
} else {
|
||||
Result.failure(Exception("Media upload failed ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyUriToTempFile(uri: Uri, extension: String = ".jpg"): File {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
?: throw IllegalStateException("Cannot open URI")
|
||||
val tempFile = File.createTempFile("upload_", ".jpg", context.cacheDir)
|
||||
val tempFile = File.createTempFile("upload_", extension, context.cacheDir)
|
||||
FileOutputStream(tempFile).use { output ->
|
||||
inputStream.copyTo(output)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
package com.swoosh.microblog.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.swoosh.microblog.data.AccountManager
|
||||
import com.swoosh.microblog.data.api.ApiClient
|
||||
import com.swoosh.microblog.data.api.GhostApiService
|
||||
import com.swoosh.microblog.data.model.GhostTagFull
|
||||
import com.swoosh.microblog.data.model.TagWrapper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class TagRepository(private val context: Context) {
|
||||
|
||||
private val accountManager = AccountManager(context)
|
||||
|
||||
private fun getApi(): GhostApiService {
|
||||
val account = accountManager.getActiveAccount()
|
||||
?: throw IllegalStateException("No active account configured")
|
||||
return ApiClient.getService(account.blogUrl) { account.apiKey }
|
||||
}
|
||||
|
||||
suspend fun fetchTags(): Result<List<GhostTagFull>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().getTags()
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!.tags)
|
||||
} else {
|
||||
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createTag(
|
||||
name: String,
|
||||
description: String? = null,
|
||||
accentColor: String? = null
|
||||
): Result<GhostTagFull> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val tag = GhostTagFull(
|
||||
name = name,
|
||||
description = description,
|
||||
accent_color = accentColor
|
||||
)
|
||||
val response = getApi().createTag(TagWrapper(listOf(tag)))
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!.tags.first())
|
||||
} else {
|
||||
Result.failure(Exception("Create tag failed ${response.code()}: ${response.errorBody()?.string()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateTag(id: String, tag: GhostTagFull): Result<GhostTagFull> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().updateTag(id, TagWrapper(listOf(tag)))
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!.tags.first())
|
||||
} else {
|
||||
Result.failure(Exception("Update tag failed ${response.code()}: ${response.errorBody()?.string()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteTag(id: String): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().deleteTag(id)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception("Delete tag failed ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.swoosh.microblog.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Returns an appropriate icon tint color for a file based on MIME type or file name.
|
||||
* Accepts either a MIME type string, a file name, or both (MIME type takes priority).
|
||||
*/
|
||||
@Composable
|
||||
fun fileTypeColor(mimeType: String? = null, fileName: String? = null): Color {
|
||||
// Try MIME type first
|
||||
if (mimeType != null) {
|
||||
val color = colorForMimeType(mimeType)
|
||||
if (color != null) return color
|
||||
}
|
||||
// Fall back to file extension
|
||||
if (fileName != null) {
|
||||
val color = colorForFileName(fileName)
|
||||
if (color != null) return color
|
||||
}
|
||||
return MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
private fun colorForMimeType(mimeType: String): Color? = when {
|
||||
mimeType.contains("pdf") -> Color(0xFFD32F2F)
|
||||
mimeType.contains("word") || mimeType.contains("doc") -> Color(0xFF1565C0)
|
||||
mimeType.contains("text") -> Color(0xFF757575)
|
||||
mimeType.contains("spreadsheet") || mimeType.contains("excel") -> Color(0xFF2E7D32)
|
||||
mimeType.contains("presentation") || mimeType.contains("powerpoint") -> Color(0xFFE65100)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun colorForFileName(fileName: String): Color? {
|
||||
val lower = fileName.lowercase()
|
||||
return when {
|
||||
lower.endsWith(".pdf") -> Color(0xFFD32F2F)
|
||||
lower.endsWith(".doc") || lower.endsWith(".docx") -> Color(0xFF1565C0)
|
||||
lower.endsWith(".txt") || lower.endsWith(".csv") -> Color(0xFF757575)
|
||||
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> Color(0xFF2E7D32)
|
||||
lower.endsWith(".ppt") || lower.endsWith(".pptx") -> Color(0xFFE65100)
|
||||
lower.endsWith(".zip") || lower.endsWith(".rar") || lower.endsWith(".gz") -> Color(0xFF6A1B9A)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
package com.swoosh.microblog.ui.components
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
|
||||
/**
|
||||
* Video player composable using ExoPlayer.
|
||||
* Shows a play button overlay; tap to play/pause.
|
||||
* Media is only prepared (buffered) on first play tap to avoid unnecessary network usage.
|
||||
*/
|
||||
@Composable
|
||||
fun VideoPlayer(
|
||||
url: String,
|
||||
modifier: Modifier = Modifier,
|
||||
compact: Boolean = false
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var isPlaying by remember { mutableStateOf(false) }
|
||||
var showOverlay by remember { mutableStateOf(true) }
|
||||
var hasPrepared by remember { mutableStateOf(false) }
|
||||
|
||||
val exoPlayer = remember(url) {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(MediaItem.fromUri(url))
|
||||
playWhenReady = false
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(exoPlayer) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
isPlaying = playing
|
||||
if (!playing && exoPlayer.playbackState == Player.STATE_ENDED) {
|
||||
showOverlay = true
|
||||
}
|
||||
}
|
||||
}
|
||||
exoPlayer.addListener(listener)
|
||||
onDispose {
|
||||
exoPlayer.removeListener(listener)
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
val height = if (compact) 180.dp else 240.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable {
|
||||
if (isPlaying) {
|
||||
exoPlayer.pause()
|
||||
showOverlay = true
|
||||
} else {
|
||||
if (!hasPrepared) {
|
||||
exoPlayer.prepare()
|
||||
hasPrepared = true
|
||||
}
|
||||
exoPlayer.play()
|
||||
showOverlay = false
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
player = exoPlayer
|
||||
useController = false
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
// Play button overlay
|
||||
if (showOverlay || !isPlaying) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact audio player with play/pause button, progress slider, and duration text.
|
||||
* Media is only prepared (buffered) on first play tap to avoid unnecessary network usage.
|
||||
*/
|
||||
@Composable
|
||||
fun AudioPlayer(
|
||||
url: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var isPlaying by remember { mutableStateOf(false) }
|
||||
var currentPosition by remember { mutableLongStateOf(0L) }
|
||||
var duration by remember { mutableLongStateOf(0L) }
|
||||
var hasPrepared by remember { mutableStateOf(false) }
|
||||
|
||||
val exoPlayer = remember(url) {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(MediaItem.fromUri(url))
|
||||
playWhenReady = false
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(exoPlayer) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
isPlaying = playing
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
duration = exoPlayer.duration.coerceAtLeast(0L)
|
||||
}
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
isPlaying = false
|
||||
exoPlayer.seekTo(0)
|
||||
exoPlayer.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
exoPlayer.addListener(listener)
|
||||
onDispose {
|
||||
exoPlayer.removeListener(listener)
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
// Update position periodically while playing
|
||||
LaunchedEffect(isPlaying) {
|
||||
while (isPlaying) {
|
||||
currentPosition = exoPlayer.currentPosition.coerceAtLeast(0L)
|
||||
kotlinx.coroutines.delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedCard(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Music note icon
|
||||
Icon(
|
||||
imageVector = Icons.Default.MusicNote,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Play/Pause button
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isPlaying) {
|
||||
exoPlayer.pause()
|
||||
} else {
|
||||
if (!hasPrepared) {
|
||||
exoPlayer.prepare()
|
||||
hasPrepared = true
|
||||
}
|
||||
exoPlayer.play()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
// Progress slider
|
||||
Slider(
|
||||
value = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f,
|
||||
onValueChange = { fraction ->
|
||||
val newPosition = (fraction * duration).toLong()
|
||||
exoPlayer.seekTo(newPosition)
|
||||
currentPosition = newPosition
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = MaterialTheme.colorScheme.primary,
|
||||
activeTrackColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Duration text
|
||||
Text(
|
||||
text = formatDuration(if (isPlaying) currentPosition else duration),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(millis: Long): String {
|
||||
if (millis <= 0) return "0:00"
|
||||
val totalSeconds = millis / 1000
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return "$minutes:${seconds.toString().padStart(2, '0')}"
|
||||
}
|
||||
|
|
@ -14,6 +14,8 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
|||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
|
|
@ -36,15 +38,22 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil.compose.AsyncImage
|
||||
import com.swoosh.microblog.data.AccountManager
|
||||
import com.swoosh.microblog.data.HashtagParser
|
||||
import com.swoosh.microblog.data.SiteMetadataCache
|
||||
import com.swoosh.microblog.data.model.FeedPost
|
||||
import com.swoosh.microblog.data.model.GhostNewsletter
|
||||
import com.swoosh.microblog.data.model.GhostTagFull
|
||||
import com.swoosh.microblog.data.model.PostStats
|
||||
import com.swoosh.microblog.ui.animation.SwooshMotion
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -105,8 +114,31 @@ fun ComposerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// File picker
|
||||
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
viewModel.addFile(uri)
|
||||
}
|
||||
}
|
||||
|
||||
// Video picker
|
||||
val videoPickerLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let { viewModel.setVideo(it) }
|
||||
}
|
||||
|
||||
// Audio picker
|
||||
val audioPickerLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let { viewModel.setAudio(it) }
|
||||
}
|
||||
|
||||
val canSubmit by remember {
|
||||
derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty()) }
|
||||
derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty() || state.fileUri != null || state.videoUri != null || state.audioUri != null) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
|
|
@ -143,15 +175,23 @@ fun ComposerScreen(
|
|||
)
|
||||
}
|
||||
} else {
|
||||
val isNewsletterPublish = state.sendAsNewsletter && state.selectedNewsletter != null
|
||||
FilledIconButton(
|
||||
onClick = viewModel::publish,
|
||||
enabled = canSubmit,
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
containerColor = if (isNewsletterPublish)
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
contentColor = if (isNewsletterPublish)
|
||||
MaterialTheme.colorScheme.onTertiaryContainer
|
||||
else MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Send, "Publish")
|
||||
Icon(
|
||||
if (isNewsletterPublish) Icons.Default.Email else Icons.Default.Send,
|
||||
if (isNewsletterPublish) "Publish & Send Email" else "Publish"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -171,13 +211,24 @@ fun ComposerScreen(
|
|||
expanded = showSendMenu,
|
||||
onDismissRequest = { showSendMenu = false }
|
||||
) {
|
||||
val publishLabel = if (state.sendAsNewsletter && state.selectedNewsletter != null) {
|
||||
if (state.isEditing) "Update & Send Email" else "Publish & Send Email"
|
||||
} else {
|
||||
if (state.isEditing) "Update & Publish" else "Publish Now"
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (state.isEditing) "Update & Publish" else "Publish Now") },
|
||||
text = { Text(publishLabel) },
|
||||
onClick = {
|
||||
showSendMenu = false
|
||||
viewModel.publish()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Send, null) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (state.sendAsNewsletter && state.selectedNewsletter != null)
|
||||
Icons.Default.Email else Icons.Default.Send,
|
||||
null
|
||||
)
|
||||
},
|
||||
enabled = canSubmit
|
||||
)
|
||||
DropdownMenuItem(
|
||||
|
|
@ -198,6 +249,31 @@ fun ComposerScreen(
|
|||
leadingIcon = { Icon(Icons.Default.Schedule, null) },
|
||||
enabled = canSubmit
|
||||
)
|
||||
|
||||
// Email-only option
|
||||
if (state.newsletterEnabled) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
DropdownMenuItem(
|
||||
text = { Text("Send via Email Only") },
|
||||
onClick = {
|
||||
showSendMenu = false
|
||||
viewModel.sendEmailOnly()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Email, null) },
|
||||
enabled = canSubmit
|
||||
)
|
||||
}
|
||||
|
||||
// Newsletter options
|
||||
if (state.newsletterEnabled) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
NewsletterDropdownSection(
|
||||
state = state,
|
||||
onToggleSendAsNewsletter = viewModel::toggleSendAsNewsletter,
|
||||
onSelectNewsletter = viewModel::selectNewsletter,
|
||||
onSetEmailSegment = viewModel::setEmailSegment
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -209,6 +285,39 @@ fun ComposerScreen(
|
|||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// "Publishing to" chip when multiple accounts exist
|
||||
val composerContext = LocalContext.current
|
||||
val composerAccountManager = remember { AccountManager(composerContext) }
|
||||
val composerAccounts = remember { composerAccountManager.getAccounts() }
|
||||
val composerActiveAccount = remember { composerAccountManager.getActiveAccount() }
|
||||
|
||||
if (composerAccounts.size > 1 && composerActiveAccount != null) {
|
||||
val composerSiteCache = remember { SiteMetadataCache(composerContext) }
|
||||
val composerSiteData = remember {
|
||||
composerSiteCache.get(composerActiveAccount.id)
|
||||
}
|
||||
val siteName = composerSiteData?.title ?: composerActiveAccount.name
|
||||
val siteIconUrl = composerSiteData?.icon ?: composerSiteData?.logo
|
||||
|
||||
AssistChip(
|
||||
onClick = { },
|
||||
label = { Text("Publishing to: $siteName") },
|
||||
leadingIcon = if (siteIconUrl != null) {
|
||||
{
|
||||
AsyncImage(
|
||||
model = siteIconUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Hashtag visual transformation for edit mode text field
|
||||
val hashtagColor = MaterialTheme.colorScheme.primary
|
||||
val hashtagTransformation = remember(hashtagColor) {
|
||||
|
|
@ -323,48 +432,17 @@ fun ComposerScreen(
|
|||
}
|
||||
)
|
||||
|
||||
// Extracted tags preview chips
|
||||
AnimatedVisibility(
|
||||
visible = state.extractedTags.isNotEmpty(),
|
||||
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
|
||||
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Tag,
|
||||
contentDescription = "Tags",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
state.extractedTags.forEach { tag ->
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(
|
||||
"#$tag",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
},
|
||||
colors = SuggestionChipDefaults.suggestionChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
labelColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
),
|
||||
border = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tags section: input + suggestions + chips (only when tags enabled)
|
||||
if (viewModel.isTagsEnabled()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
TagsSection(
|
||||
tagInput = state.tagInput,
|
||||
onTagInputChange = viewModel::updateTagInput,
|
||||
tagSuggestions = state.tagSuggestions,
|
||||
extractedTags = state.extractedTags,
|
||||
onAddTag = viewModel::addTag,
|
||||
onRemoveTag = viewModel::removeTag
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
|
@ -376,9 +454,21 @@ fun ComposerScreen(
|
|||
OutlinedIconButton(onClick = { multiImagePickerLauncher.launch("image/*") }) {
|
||||
Icon(Icons.Default.Image, "Attach images")
|
||||
}
|
||||
OutlinedIconButton(onClick = { videoPickerLauncher.launch("video/*") }) {
|
||||
Icon(Icons.Default.Videocam, "Attach video")
|
||||
}
|
||||
OutlinedIconButton(onClick = { audioPickerLauncher.launch("audio/*") }) {
|
||||
Icon(Icons.Default.MusicNote, "Attach audio")
|
||||
}
|
||||
OutlinedIconButton(onClick = { showLinkDialog = true }) {
|
||||
Icon(Icons.Default.Link, "Add link")
|
||||
}
|
||||
OutlinedIconButton(
|
||||
onClick = { filePickerLauncher.launch("application/*") },
|
||||
enabled = state.fileUri == null
|
||||
) {
|
||||
Icon(Icons.Default.AttachFile, "Attach file")
|
||||
}
|
||||
}
|
||||
|
||||
// Image grid preview (multi-image) — 120dp thumbnails
|
||||
|
|
@ -428,6 +518,57 @@ fun ComposerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Video preview card
|
||||
AnimatedVisibility(
|
||||
visible = state.videoUri != null,
|
||||
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
|
||||
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
MediaPreviewCard(
|
||||
icon = Icons.Default.Videocam,
|
||||
label = "Video attached",
|
||||
uri = state.videoUri,
|
||||
onRemove = viewModel::removeVideo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Audio preview card
|
||||
AnimatedVisibility(
|
||||
visible = state.audioUri != null,
|
||||
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
|
||||
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
MediaPreviewCard(
|
||||
icon = Icons.Default.MusicNote,
|
||||
label = "Audio attached",
|
||||
uri = state.audioUri,
|
||||
onRemove = viewModel::removeAudio
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// File attachment card
|
||||
AnimatedVisibility(
|
||||
visible = state.fileUri != null,
|
||||
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
|
||||
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FileAttachmentComposerCard(
|
||||
fileName = state.fileName ?: "file",
|
||||
fileSize = state.fileSize ?: 0,
|
||||
fileMimeType = state.fileMimeType,
|
||||
onRemove = viewModel::removeFile
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Link preview
|
||||
AnimatedVisibility(
|
||||
visible = state.isLoadingLink,
|
||||
|
|
@ -685,6 +826,384 @@ fun ComposerScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Newsletter confirmation dialog
|
||||
val confirmNewsletter = state.selectedNewsletter
|
||||
if (state.showNewsletterConfirmation && confirmNewsletter != null) {
|
||||
NewsletterConfirmationDialog(
|
||||
newsletterName = confirmNewsletter.name,
|
||||
emailSegment = state.emailSegment,
|
||||
subscriberCount = state.subscriberCount,
|
||||
postTitle = state.text.take(60),
|
||||
onConfirm = viewModel::confirmNewsletterSend,
|
||||
onDismiss = viewModel::cancelNewsletterConfirmation
|
||||
)
|
||||
}
|
||||
|
||||
// Email-only confirmation dialog
|
||||
if (state.showEmailOnlyConfirmation) {
|
||||
EmailOnlyConfirmationDialog(
|
||||
postPreview = state.text.take(80),
|
||||
availableNewsletters = state.availableNewsletters,
|
||||
selectedNewsletter = state.selectedNewsletter,
|
||||
onSelectNewsletter = viewModel::selectNewsletter,
|
||||
onConfirm = viewModel::confirmEmailOnly,
|
||||
onDismiss = viewModel::cancelEmailOnly
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Newsletter options section in the publish dropdown menu.
|
||||
*/
|
||||
@Composable
|
||||
fun NewsletterDropdownSection(
|
||||
state: ComposerUiState,
|
||||
onToggleSendAsNewsletter: () -> Unit,
|
||||
onSelectNewsletter: (GhostNewsletter) -> Unit,
|
||||
onSetEmailSegment: (String) -> Unit
|
||||
) {
|
||||
// Send as newsletter switch
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggleSendAsNewsletter)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Send as newsletter",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = state.sendAsNewsletter,
|
||||
onCheckedChange = { onToggleSendAsNewsletter() },
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Newsletter picker and segment (only when sending)
|
||||
AnimatedVisibility(
|
||||
visible = state.sendAsNewsletter,
|
||||
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
|
||||
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
|
||||
// Newsletter picker
|
||||
if (state.availableNewsletters.size > 1) {
|
||||
Text(
|
||||
text = "Newsletter:",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
Column(modifier = Modifier.selectableGroup()) {
|
||||
state.availableNewsletters.forEach { newsletter ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = state.selectedNewsletter?.id == newsletter.id,
|
||||
onClick = { onSelectNewsletter(newsletter) }
|
||||
)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = state.selectedNewsletter?.id == newsletter.id,
|
||||
onClick = { onSelectNewsletter(newsletter) }
|
||||
)
|
||||
Text(
|
||||
text = newsletter.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (state.availableNewsletters.size == 1) {
|
||||
Text(
|
||||
text = "Newsletter: ${state.availableNewsletters.first().name}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Segment picker
|
||||
Text(
|
||||
text = "Send to:",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
val segments = listOf("all" to "All subscribers", "status:free" to "Free members", "status:-free" to "Paid members")
|
||||
Column(modifier = Modifier.selectableGroup()) {
|
||||
segments.forEach { (value, label) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = state.emailSegment == value,
|
||||
onClick = { onSetEmailSegment(value) }
|
||||
)
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = state.emailSegment == value,
|
||||
onClick = { onSetEmailSegment(value) }
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Warning text
|
||||
val countText = state.subscriberCount?.let { "~$it" } ?: "your"
|
||||
Text(
|
||||
text = "\u26A0 Email will be sent to $countText subscribers. This cannot be undone.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation dialog for newsletter sending.
|
||||
* Requires typing "WYSLIJ" to confirm.
|
||||
*/
|
||||
@Composable
|
||||
fun NewsletterConfirmationDialog(
|
||||
newsletterName: String,
|
||||
emailSegment: String,
|
||||
subscriberCount: Int?,
|
||||
postTitle: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var confirmInput by remember { mutableStateOf("") }
|
||||
val isConfirmEnabled = confirmInput == "WYSLIJ"
|
||||
val segmentLabel = when (emailSegment) {
|
||||
"all" -> "All subscribers"
|
||||
"status:free" -> "Free members"
|
||||
"status:-free" -> "Paid members"
|
||||
else -> emailSegment
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = "Confirm Newsletter Send",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = "You are about to send an email newsletter:",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Summary card
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = "Newsletter: $newsletterName",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "Segment: $segmentLabel",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
if (subscriberCount != null) {
|
||||
Text(
|
||||
text = "Recipients: ~$subscriberCount",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Post: ${postTitle.take(40)}${if (postTitle.length > 40) "..." else ""}",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Warning
|
||||
Text(
|
||||
text = "\u26A0 IRREVERSIBLE: Once sent, this email cannot be recalled.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Confirmation input
|
||||
Text(
|
||||
text = "Type WYSLIJ to confirm:",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedTextField(
|
||||
value = confirmInput,
|
||||
onValueChange = { confirmInput = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
placeholder = { Text("WYSLIJ") }
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
enabled = isConfirmEnabled,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Email,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Send Email")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation dialog for email-only sending.
|
||||
* Shows a warning that the post will NOT appear on the blog.
|
||||
*/
|
||||
@Composable
|
||||
fun EmailOnlyConfirmationDialog(
|
||||
postPreview: String,
|
||||
availableNewsletters: List<GhostNewsletter>,
|
||||
selectedNewsletter: GhostNewsletter?,
|
||||
onSelectNewsletter: (GhostNewsletter) -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Send via email only?",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
// Post content preview
|
||||
if (postPreview.isNotBlank()) {
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = postPreview + if (postPreview.length >= 80) "..." else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
// Newsletter picker (only if multiple)
|
||||
if (availableNewsletters.size > 1) {
|
||||
Text(
|
||||
text = "Newsletter:",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Column(modifier = Modifier.selectableGroup()) {
|
||||
availableNewsletters.forEach { newsletter ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = selectedNewsletter?.id == newsletter.id,
|
||||
onClick = { onSelectNewsletter(newsletter) }
|
||||
)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedNewsletter?.id == newsletter.id,
|
||||
onClick = { onSelectNewsletter(newsletter) }
|
||||
)
|
||||
Text(
|
||||
text = newsletter.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
} else if (selectedNewsletter != null) {
|
||||
Text(
|
||||
text = "Newsletter: ${selectedNewsletter.name}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
// Bold warning
|
||||
Text(
|
||||
text = "This cannot be undone. Post will NOT appear on blog.",
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
) {
|
||||
Text("\u2709 SEND EMAIL")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -878,3 +1397,276 @@ class HashtagVisualTransformation(private val hashtagColor: Color) : VisualTrans
|
|||
return TransformedText(annotated, OffsetMapping.Identity)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact preview card for attached video or audio files.
|
||||
* Shows an icon, label, filename (from URI), and a remove button.
|
||||
*/
|
||||
@Composable
|
||||
fun MediaPreviewCard(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
uri: Uri?,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val fileName = remember(uri) {
|
||||
uri?.let { mediaUri ->
|
||||
try {
|
||||
context.contentResolver.query(mediaUri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (cursor.moveToFirst() && nameIndex >= 0) {
|
||||
cursor.getString(nameIndex)
|
||||
} else null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: mediaUri.lastPathSegment ?: "Unknown file"
|
||||
} ?: "Unknown file"
|
||||
}
|
||||
|
||||
val fileSize = remember(uri) {
|
||||
uri?.let { mediaUri ->
|
||||
try {
|
||||
context.contentResolver.query(mediaUri, null, null, null, null)?.use { cursor ->
|
||||
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
|
||||
if (cursor.moveToFirst() && sizeIndex >= 0) {
|
||||
formatFileSize(cursor.getLong(sizeIndex))
|
||||
} else null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = fileName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||||
)
|
||||
if (fileSize != null) {
|
||||
Text(
|
||||
text = fileSize,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(Icons.Default.Close, "Remove $label", Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag input section with autocomplete suggestions and tag chips.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun TagsSection(
|
||||
tagInput: String,
|
||||
onTagInputChange: (String) -> Unit,
|
||||
tagSuggestions: List<GhostTagFull>,
|
||||
extractedTags: List<String>,
|
||||
onAddTag: (String) -> Unit,
|
||||
onRemoveTag: (String) -> Unit
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "Tags:",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Tag input field
|
||||
Box {
|
||||
OutlinedTextField(
|
||||
value = tagInput,
|
||||
onValueChange = onTagInputChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Add a tag...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Tag,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Suggestions dropdown
|
||||
DropdownMenu(
|
||||
expanded = tagSuggestions.isNotEmpty() || tagInput.isNotBlank(),
|
||||
onDismissRequest = { onTagInputChange("") },
|
||||
modifier = Modifier.fillMaxWidth(0.9f)
|
||||
) {
|
||||
tagSuggestions.take(5).forEach { tag ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(tag.name)
|
||||
if (tag.count?.posts != null) {
|
||||
Text(
|
||||
"(${tag.count.posts})",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = { onAddTag(tag.name) }
|
||||
)
|
||||
}
|
||||
|
||||
// "Create new" option when input doesn't exactly match an existing tag
|
||||
if (tagInput.isNotBlank() && tagSuggestions.none {
|
||||
it.name.equals(tagInput, ignoreCase = true)
|
||||
}) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
"+ Create '$tagInput' as new",
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
onClick = { onAddTag(tagInput) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Added tags as chips
|
||||
AnimatedVisibility(
|
||||
visible = extractedTags.isNotEmpty(),
|
||||
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
|
||||
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
extractedTags.forEach { tag ->
|
||||
InputChip(
|
||||
selected = false,
|
||||
onClick = { onRemoveTag(tag) },
|
||||
label = {
|
||||
Text(
|
||||
"#$tag",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove tag $tag",
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
},
|
||||
colors = InputChipDefaults.inputChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
labelColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
),
|
||||
border = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Formats file size in human-readable units.
|
||||
*/
|
||||
private fun formatFileSize(bytes: Long): String {
|
||||
return when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
else -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File attachment card shown in the composer with file info and remove button.
|
||||
*/
|
||||
@Composable
|
||||
fun FileAttachmentComposerCard(
|
||||
fileName: String,
|
||||
fileSize: Long,
|
||||
fileMimeType: String?,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val iconTint = com.swoosh.microblog.ui.components.fileTypeColor(mimeType = fileMimeType)
|
||||
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.InsertDriveFile,
|
||||
contentDescription = "File",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = iconTint
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = fileName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (fileSize > 0) {
|
||||
Text(
|
||||
text = formatFileSize(fileSize),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove file",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ import androidx.lifecycle.AndroidViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.swoosh.microblog.data.HashtagParser
|
||||
import com.swoosh.microblog.data.MobiledocBuilder
|
||||
import com.swoosh.microblog.data.NewsletterPreferences
|
||||
import com.swoosh.microblog.data.TagsPreferences
|
||||
import com.swoosh.microblog.data.PreviewHtmlBuilder
|
||||
import com.swoosh.microblog.data.db.Converters
|
||||
import com.swoosh.microblog.data.model.*
|
||||
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
||||
import com.swoosh.microblog.data.repository.PostRepository
|
||||
import com.swoosh.microblog.data.repository.TagRepository
|
||||
import com.swoosh.microblog.worker.PostUploadWorker
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
|
@ -25,6 +28,9 @@ import kotlinx.coroutines.launch
|
|||
class ComposerViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val repository = PostRepository(application)
|
||||
private val tagRepository = TagRepository(application)
|
||||
private val newsletterPreferences = NewsletterPreferences(application)
|
||||
private val tagsPreferences = TagsPreferences(application)
|
||||
private val appContext = application
|
||||
|
||||
private val _uiState = MutableStateFlow(ComposerUiState())
|
||||
|
|
@ -36,6 +42,157 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
|
||||
private var previewDebounceJob: Job? = null
|
||||
|
||||
init {
|
||||
if (tagsPreferences.isTagsEnabled()) {
|
||||
loadAvailableTags()
|
||||
}
|
||||
loadNewsletterData()
|
||||
}
|
||||
|
||||
fun isTagsEnabled(): Boolean = tagsPreferences.isTagsEnabled()
|
||||
|
||||
private fun loadAvailableTags() {
|
||||
viewModelScope.launch {
|
||||
tagRepository.fetchTags().fold(
|
||||
onSuccess = { tags ->
|
||||
_uiState.update { it.copy(availableTags = tags) }
|
||||
},
|
||||
onFailure = { /* silently ignore - tags are optional */ }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadNewsletterData() {
|
||||
val enabled = newsletterPreferences.isNewsletterEnabled()
|
||||
_uiState.update { it.copy(newsletterEnabled = enabled) }
|
||||
if (enabled) {
|
||||
viewModelScope.launch {
|
||||
// Fetch available newsletters
|
||||
repository.fetchNewsletters().fold(
|
||||
onSuccess = { newsletters ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
availableNewsletters = newsletters,
|
||||
selectedNewsletter = newsletters.firstOrNull()
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { /* silently ignore */ }
|
||||
)
|
||||
// Fetch subscriber count (lightweight: limit=1, read meta.pagination.total)
|
||||
repository.fetchSubscriberCount().fold(
|
||||
onSuccess = { count ->
|
||||
_uiState.update { it.copy(subscriberCount = count) }
|
||||
},
|
||||
onFailure = { /* silently ignore */ }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSendAsNewsletter() {
|
||||
_uiState.update { it.copy(sendAsNewsletter = !it.sendAsNewsletter) }
|
||||
}
|
||||
|
||||
fun selectNewsletter(newsletter: GhostNewsletter) {
|
||||
_uiState.update { it.copy(selectedNewsletter = newsletter) }
|
||||
}
|
||||
|
||||
fun setEmailSegment(segment: String) {
|
||||
_uiState.update { it.copy(emailSegment = segment) }
|
||||
}
|
||||
|
||||
fun confirmNewsletterSend() {
|
||||
_uiState.update { it.copy(showNewsletterConfirmation = false) }
|
||||
publish()
|
||||
}
|
||||
|
||||
fun cancelNewsletterConfirmation() {
|
||||
_uiState.update { it.copy(showNewsletterConfirmation = false) }
|
||||
}
|
||||
|
||||
fun sendEmailOnly() {
|
||||
_uiState.update { it.copy(showEmailOnlyConfirmation = true) }
|
||||
}
|
||||
|
||||
fun confirmEmailOnly() {
|
||||
_uiState.update { it.copy(showEmailOnlyConfirmation = false) }
|
||||
submitEmailOnlyPost()
|
||||
}
|
||||
|
||||
fun cancelEmailOnly() {
|
||||
_uiState.update { it.copy(showEmailOnlyConfirmation = false) }
|
||||
}
|
||||
|
||||
private fun submitEmailOnlyPost() {
|
||||
val state = _uiState.value
|
||||
if (state.text.isBlank() && state.imageUris.isEmpty() && state.fileUri == null) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
||||
|
||||
val title = state.text.take(60)
|
||||
val hashtagTags = HashtagParser.parse(state.text)
|
||||
val allTags = (state.extractedTags + hashtagTags).distinctBy { it.lowercase() }
|
||||
val tagsJson = Gson().toJson(allTags)
|
||||
val altText = state.imageAlt.ifBlank { null }
|
||||
val newsletterSlug = state.selectedNewsletter?.slug
|
||||
|
||||
// Save locally and queue for upload
|
||||
val localPost = LocalPost(
|
||||
localId = editingLocalId ?: 0,
|
||||
ghostId = editingGhostId,
|
||||
title = title,
|
||||
content = state.text,
|
||||
status = PostStatus.PUBLISHED,
|
||||
featured = false,
|
||||
imageUri = state.imageUris.firstOrNull()?.toString(),
|
||||
imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }),
|
||||
imageAlt = altText,
|
||||
linkUrl = state.linkPreview?.url,
|
||||
linkTitle = state.linkPreview?.title,
|
||||
linkDescription = state.linkPreview?.description,
|
||||
linkImageUrl = state.linkPreview?.imageUrl,
|
||||
tags = tagsJson,
|
||||
queueStatus = QueueStatus.QUEUED_EMAIL_ONLY,
|
||||
emailOnly = true,
|
||||
newsletterSlug = newsletterSlug,
|
||||
fileUri = state.fileUri?.toString(),
|
||||
fileName = state.fileName
|
||||
)
|
||||
repository.saveLocalPost(localPost)
|
||||
PostUploadWorker.enqueue(appContext)
|
||||
|
||||
_uiState.update { it.copy(isSubmitting = false, isSuccess = true) }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTagInput(input: String) {
|
||||
val suggestions = if (input.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
_uiState.value.availableTags.filter { tag ->
|
||||
tag.name.contains(input, ignoreCase = true) &&
|
||||
!_uiState.value.extractedTags.any { it.equals(tag.name, ignoreCase = true) }
|
||||
}.take(5)
|
||||
}
|
||||
_uiState.update { it.copy(tagInput = input, tagSuggestions = suggestions) }
|
||||
}
|
||||
|
||||
fun addTag(tagName: String) {
|
||||
val currentTags = _uiState.value.extractedTags.toMutableList()
|
||||
if (!currentTags.any { it.equals(tagName, ignoreCase = true) }) {
|
||||
currentTags.add(tagName)
|
||||
}
|
||||
_uiState.update { it.copy(extractedTags = currentTags, tagInput = "", tagSuggestions = emptyList()) }
|
||||
}
|
||||
|
||||
fun removeTag(tagName: String) {
|
||||
val currentTags = _uiState.value.extractedTags.toMutableList()
|
||||
currentTags.removeAll { it.equals(tagName, ignoreCase = true) }
|
||||
_uiState.update { it.copy(extractedTags = currentTags) }
|
||||
}
|
||||
|
||||
fun loadForEdit(post: FeedPost) {
|
||||
editingLocalId = post.localId
|
||||
editingGhostId = post.ghostId
|
||||
|
|
@ -54,6 +211,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
text = post.textContent,
|
||||
imageUris = imageUris,
|
||||
imageAlt = post.imageAlt ?: "",
|
||||
videoUri = post.videoUrl?.let { url -> Uri.parse(url) },
|
||||
audioUri = post.audioUrl?.let { url -> Uri.parse(url) },
|
||||
linkPreview = if (post.linkUrl != null) LinkPreview(
|
||||
url = post.linkUrl,
|
||||
title = post.linkTitle,
|
||||
|
|
@ -113,6 +272,73 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a file to the post. Reads filename and size from ContentResolver.
|
||||
* Validates type and size (max 50 MB).
|
||||
*/
|
||||
fun addFile(uri: Uri) {
|
||||
val contentResolver = appContext.contentResolver
|
||||
var name: String? = null
|
||||
var size: Long? = null
|
||||
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
|
||||
if (nameIndex >= 0) name = cursor.getString(nameIndex)
|
||||
if (sizeIndex >= 0) size = cursor.getLong(sizeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
|
||||
// Validate size: max 50 MB
|
||||
val maxSize = 50L * 1024 * 1024
|
||||
if (size != null && size!! > maxSize) {
|
||||
_uiState.update { it.copy(error = "File too large. Maximum file size is 50 MB.") }
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
fileUri = uri,
|
||||
fileName = name ?: "file",
|
||||
fileSize = size,
|
||||
fileMimeType = mimeType,
|
||||
uploadedFileUrl = null,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFile() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
fileUri = null,
|
||||
fileName = null,
|
||||
fileSize = null,
|
||||
fileMimeType = null,
|
||||
uploadedFileUrl = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setVideo(uri: Uri) {
|
||||
_uiState.update { it.copy(videoUri = uri) }
|
||||
}
|
||||
|
||||
fun removeVideo() {
|
||||
_uiState.update { it.copy(videoUri = null, uploadedVideoUrl = null) }
|
||||
}
|
||||
|
||||
fun setAudio(uri: Uri) {
|
||||
_uiState.update { it.copy(audioUri = uri) }
|
||||
}
|
||||
|
||||
fun removeAudio() {
|
||||
_uiState.update { it.copy(audioUri = null, uploadedAudioUrl = null) }
|
||||
}
|
||||
|
||||
fun fetchLinkPreview(url: String) {
|
||||
if (url.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
|
|
@ -180,7 +406,15 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
_uiState.update { it.copy(featured = !it.featured) }
|
||||
}
|
||||
|
||||
fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH)
|
||||
fun publish() {
|
||||
val state = _uiState.value
|
||||
if (state.sendAsNewsletter && state.selectedNewsletter != null && !state.showNewsletterConfirmation) {
|
||||
// Show confirmation dialog before sending as newsletter
|
||||
_uiState.update { it.copy(showNewsletterConfirmation = true) }
|
||||
return
|
||||
}
|
||||
submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH)
|
||||
}
|
||||
|
||||
fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE)
|
||||
|
||||
|
|
@ -191,14 +425,16 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
|
||||
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
|
||||
val state = _uiState.value
|
||||
if (state.text.isBlank() && state.imageUris.isEmpty()) return
|
||||
if (state.text.isBlank() && state.imageUris.isEmpty() && state.fileUri == null && state.videoUri == null && state.audioUri == null) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
||||
|
||||
val title = state.text.take(60)
|
||||
val extractedTags = HashtagParser.parse(state.text)
|
||||
val tagsJson = Gson().toJson(extractedTags)
|
||||
// Merge hashtag-parsed tags with manually-added tags (deduplicated)
|
||||
val hashtagTags = HashtagParser.parse(state.text)
|
||||
val allTags = (state.extractedTags + hashtagTags).distinctBy { it.lowercase() }
|
||||
val tagsJson = Gson().toJson(allTags)
|
||||
|
||||
val altText = state.imageAlt.ifBlank { null }
|
||||
|
||||
|
|
@ -214,13 +450,18 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
imageUri = state.imageUris.firstOrNull()?.toString(),
|
||||
imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }),
|
||||
imageAlt = altText,
|
||||
videoUri = state.videoUri?.toString(),
|
||||
audioUri = state.audioUri?.toString(),
|
||||
linkUrl = state.linkPreview?.url,
|
||||
linkTitle = state.linkPreview?.title,
|
||||
linkDescription = state.linkPreview?.description,
|
||||
linkImageUrl = state.linkPreview?.imageUrl,
|
||||
scheduledAt = state.scheduledAt,
|
||||
tags = tagsJson,
|
||||
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus
|
||||
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus,
|
||||
fileUri = state.fileUri?.toString(),
|
||||
fileName = state.fileName,
|
||||
newsletterSlug = if (state.sendAsNewsletter) state.selectedNewsletter?.slug else null
|
||||
)
|
||||
repository.saveLocalPost(localPost)
|
||||
|
||||
|
|
@ -245,14 +486,66 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
)
|
||||
}
|
||||
|
||||
// Upload video if present
|
||||
var videoUrl: String? = state.uploadedVideoUrl
|
||||
if (videoUrl == null && state.videoUri != null) {
|
||||
_uiState.update { it.copy(isUploadingMedia = true) }
|
||||
val videoResult = repository.uploadMediaFile(state.videoUri)
|
||||
videoResult.fold(
|
||||
onSuccess = { url -> videoUrl = url },
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isSubmitting = false, isUploadingMedia = false, error = "Video upload failed: ${e.message}") }
|
||||
return@launch
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Upload audio if present
|
||||
var audioUrl: String? = state.uploadedAudioUrl
|
||||
if (audioUrl == null && state.audioUri != null) {
|
||||
_uiState.update { it.copy(isUploadingMedia = true) }
|
||||
val audioResult = repository.uploadMediaFile(state.audioUri)
|
||||
audioResult.fold(
|
||||
onSuccess = { url -> audioUrl = url },
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isSubmitting = false, isUploadingMedia = false, error = "Audio upload failed: ${e.message}") }
|
||||
return@launch
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(isUploadingMedia = false) }
|
||||
|
||||
// Upload file if attached
|
||||
var uploadedFileUrl = state.uploadedFileUrl
|
||||
if (state.fileUri != null && uploadedFileUrl == null) {
|
||||
val fileResult = repository.uploadFile(state.fileUri)
|
||||
fileResult.fold(
|
||||
onSuccess = { url -> uploadedFileUrl = url },
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isSubmitting = false, error = "File upload failed: ${e.message}") }
|
||||
return@launch
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
val featureImage = uploadedImageUrls.firstOrNull()
|
||||
|
||||
val mobiledoc = MobiledocBuilder.build(
|
||||
state.text, uploadedImageUrls,
|
||||
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
|
||||
altText
|
||||
text = state.text,
|
||||
imageUrls = uploadedImageUrls,
|
||||
linkUrl = state.linkPreview?.url,
|
||||
linkTitle = state.linkPreview?.title,
|
||||
linkDescription = state.linkPreview?.description,
|
||||
imageAlt = altText,
|
||||
videoUrl = videoUrl,
|
||||
audioUrl = audioUrl,
|
||||
fileUrl = uploadedFileUrl,
|
||||
fileName = state.fileName,
|
||||
fileSize = state.fileSize ?: 0
|
||||
)
|
||||
val ghostTags = extractedTags.map { GhostTag(name = it) }
|
||||
val ghostTags = allTags.map { GhostTag(name = it) }
|
||||
|
||||
val ghostPost = GhostPost(
|
||||
title = title,
|
||||
|
|
@ -266,11 +559,17 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
tags = ghostTags.ifEmpty { null }
|
||||
)
|
||||
|
||||
// Determine newsletter params
|
||||
val newsletterSlug = if (state.sendAsNewsletter && state.selectedNewsletter != null) {
|
||||
state.selectedNewsletter.slug
|
||||
} else null
|
||||
val emailSeg = if (newsletterSlug != null) state.emailSegment else null
|
||||
|
||||
val result = if (editingGhostId != null) {
|
||||
val updatePost = ghostPost.copy(updated_at = editingUpdatedAt)
|
||||
repository.updatePost(editingGhostId!!, updatePost)
|
||||
repository.updatePost(editingGhostId!!, updatePost, newsletter = newsletterSlug, emailSegment = emailSeg)
|
||||
} else {
|
||||
repository.createPost(ghostPost)
|
||||
repository.createPost(ghostPost, newsletter = newsletterSlug, emailSegment = emailSeg)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
|
|
@ -291,13 +590,21 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
uploadedImageUrl = featureImage,
|
||||
uploadedImageUrls = Converters.stringListToJson(uploadedImageUrls),
|
||||
imageAlt = altText,
|
||||
videoUri = state.videoUri?.toString(),
|
||||
uploadedVideoUrl = videoUrl,
|
||||
audioUri = state.audioUri?.toString(),
|
||||
uploadedAudioUrl = audioUrl,
|
||||
linkUrl = state.linkPreview?.url,
|
||||
linkTitle = state.linkPreview?.title,
|
||||
linkDescription = state.linkPreview?.description,
|
||||
linkImageUrl = state.linkPreview?.imageUrl,
|
||||
scheduledAt = state.scheduledAt,
|
||||
tags = tagsJson,
|
||||
queueStatus = offlineQueueStatus
|
||||
queueStatus = offlineQueueStatus,
|
||||
fileUri = state.fileUri?.toString(),
|
||||
uploadedFileUrl = uploadedFileUrl,
|
||||
fileName = state.fileName,
|
||||
newsletterSlug = if (state.sendAsNewsletter) state.selectedNewsletter?.slug else null
|
||||
)
|
||||
repository.saveLocalPost(localPost)
|
||||
PostUploadWorker.enqueue(appContext)
|
||||
|
|
@ -324,17 +631,41 @@ data class ComposerUiState(
|
|||
val text: String = "",
|
||||
val imageUris: List<Uri> = emptyList(),
|
||||
val imageAlt: String = "",
|
||||
val videoUri: Uri? = null,
|
||||
val audioUri: Uri? = null,
|
||||
val uploadedVideoUrl: String? = null,
|
||||
val uploadedAudioUrl: String? = null,
|
||||
val isUploadingMedia: Boolean = false,
|
||||
val linkPreview: LinkPreview? = null,
|
||||
val isLoadingLink: Boolean = false,
|
||||
val scheduledAt: String? = null,
|
||||
val featured: Boolean = false,
|
||||
val extractedTags: List<String> = emptyList(),
|
||||
val availableTags: List<GhostTagFull> = emptyList(),
|
||||
val tagSuggestions: List<GhostTagFull> = emptyList(),
|
||||
val tagInput: String = "",
|
||||
val isSubmitting: Boolean = false,
|
||||
val isSuccess: Boolean = false,
|
||||
val isEditing: Boolean = false,
|
||||
val error: String? = null,
|
||||
val isPreviewMode: Boolean = false,
|
||||
val previewHtml: String = ""
|
||||
val previewHtml: String = "",
|
||||
// File attachment
|
||||
val fileUri: Uri? = null,
|
||||
val fileName: String? = null,
|
||||
val fileSize: Long? = null,
|
||||
val fileMimeType: String? = null,
|
||||
val uploadedFileUrl: String? = null,
|
||||
// Newsletter fields
|
||||
val newsletterEnabled: Boolean = false,
|
||||
val availableNewsletters: List<GhostNewsletter> = emptyList(),
|
||||
val selectedNewsletter: GhostNewsletter? = null,
|
||||
val sendAsNewsletter: Boolean = false,
|
||||
val emailSegment: String = "all",
|
||||
val showNewsletterConfirmation: Boolean = false,
|
||||
val subscriberCount: Int? = null,
|
||||
// Email-only
|
||||
val showEmailOnlyConfirmation: Boolean = false
|
||||
) {
|
||||
/**
|
||||
* Backwards compatibility: returns the first image URI or null.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.filled.Article
|
|||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
|
|
@ -53,7 +54,10 @@ import com.swoosh.microblog.data.model.LinkPreview
|
|||
import com.swoosh.microblog.data.model.PostStats
|
||||
import com.swoosh.microblog.data.model.QueueStatus
|
||||
import com.swoosh.microblog.ui.animation.SwooshMotion
|
||||
import com.swoosh.microblog.ui.components.AudioPlayer
|
||||
import com.swoosh.microblog.ui.components.ConfirmationDialog
|
||||
import com.swoosh.microblog.ui.components.VideoPlayer
|
||||
import com.swoosh.microblog.ui.feed.FileAttachmentCard
|
||||
import com.swoosh.microblog.ui.feed.FullScreenGallery
|
||||
import com.swoosh.microblog.ui.feed.StatusBadge
|
||||
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
||||
|
|
@ -94,7 +98,7 @@ fun DetailScreen(
|
|||
}
|
||||
|
||||
// D1: Content reveal sequence
|
||||
val revealCount = 6 // status, text, tags, gallery, link, stats
|
||||
val revealCount = 8 // status, text, tags, gallery, video, audio, link, stats
|
||||
val sectionVisible = remember { List(revealCount) { mutableStateOf(false) } }
|
||||
LaunchedEffect(Unit) {
|
||||
sectionVisible.forEachIndexed { index, state ->
|
||||
|
|
@ -300,9 +304,31 @@ fun DetailScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Section 4 — Link preview
|
||||
// Section 4 — Video player
|
||||
AnimatedVisibility(
|
||||
visible = sectionVisible[4].value && post.linkUrl != null,
|
||||
visible = sectionVisible[4].value && post.videoUrl != null,
|
||||
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
VideoPlayer(url = post.videoUrl!!, compact = false)
|
||||
}
|
||||
}
|
||||
|
||||
// Section 5 — Audio player
|
||||
AnimatedVisibility(
|
||||
visible = sectionVisible[5].value && post.audioUrl != null,
|
||||
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AudioPlayer(url = post.audioUrl!!)
|
||||
}
|
||||
}
|
||||
|
||||
// Section 6 — Link preview
|
||||
AnimatedVisibility(
|
||||
visible = sectionVisible[6].value && post.linkUrl != null,
|
||||
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
|
||||
) {
|
||||
Column {
|
||||
|
|
@ -348,9 +374,25 @@ fun DetailScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Section 5 — PostStatsSection
|
||||
// File attachment
|
||||
if (post.fileUrl != null) {
|
||||
AnimatedVisibility(
|
||||
visible = sectionVisible[6].value,
|
||||
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
FileAttachmentCard(
|
||||
fileUrl = post.fileUrl,
|
||||
fileName = post.fileName ?: "File"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Section 7 — PostStatsSection
|
||||
AnimatedVisibility(
|
||||
visible = sectionVisible[5].value,
|
||||
visible = sectionVisible[7].value,
|
||||
enter = slideInVertically(initialOffsetY = { it / 4 }, animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick())
|
||||
) {
|
||||
Column {
|
||||
|
|
@ -358,6 +400,43 @@ fun DetailScreen(
|
|||
PostStatsSection(post)
|
||||
}
|
||||
}
|
||||
|
||||
// Email-only info card
|
||||
if (post.status == "sent" || post.emailOnly) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Email,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "\u2709 SENT VIA EMAIL ONLY",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "This post is not visible on your blog.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import androidx.compose.foundation.horizontalScroll
|
|||
import androidx.compose.foundation.layout.*
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
|
|
@ -74,20 +75,26 @@ import androidx.compose.ui.text.withStyle
|
|||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import android.content.Intent
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.swoosh.microblog.data.CredentialsManager
|
||||
import com.swoosh.microblog.data.toDisplayUrl
|
||||
import com.swoosh.microblog.data.ShareUtils
|
||||
import com.swoosh.microblog.data.SiteMetadataCache
|
||||
import com.swoosh.microblog.data.model.FeedPost
|
||||
import com.swoosh.microblog.data.model.GhostAccount
|
||||
import com.swoosh.microblog.data.model.GhostTagFull
|
||||
import com.swoosh.microblog.data.model.PostFilter
|
||||
import com.swoosh.microblog.data.model.PostStats
|
||||
import com.swoosh.microblog.data.model.QueueStatus
|
||||
import com.swoosh.microblog.data.model.SortOrder
|
||||
import com.swoosh.microblog.ui.components.AudioPlayer
|
||||
import com.swoosh.microblog.ui.components.ConfirmationDialog
|
||||
import com.swoosh.microblog.ui.components.VideoPlayer
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
|
|
@ -109,10 +116,16 @@ fun FeedScreen(
|
|||
val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle()
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
val activeAccount by viewModel.activeAccount.collectAsStateWithLifecycle()
|
||||
val popularTags by viewModel.popularTags.collectAsStateWithLifecycle()
|
||||
val tagsEnabled by viewModel.tagsEnabled.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val baseUrl = remember { CredentialsManager(context).ghostUrl }
|
||||
val siteMetadataCache = remember { SiteMetadataCache(context) }
|
||||
val siteData = remember(activeAccount?.id) {
|
||||
activeAccount?.let { siteMetadataCache.get(it.id) }
|
||||
}
|
||||
|
||||
// Track which post is pending delete confirmation
|
||||
var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) }
|
||||
|
|
@ -201,8 +214,19 @@ fun FeedScreen(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { showAccountSwitcher = true }
|
||||
) {
|
||||
// Account color indicator
|
||||
if (activeAccount != null) {
|
||||
// Site icon or account avatar
|
||||
val siteIconUrl = siteData?.icon ?: siteData?.logo
|
||||
if (siteIconUrl != null) {
|
||||
AsyncImage(
|
||||
model = siteIconUrl,
|
||||
contentDescription = "Site icon",
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
} else if (activeAccount != null) {
|
||||
AccountAvatar(
|
||||
account = activeAccount!!,
|
||||
size = 28
|
||||
|
|
@ -211,18 +235,19 @@ fun FeedScreen(
|
|||
}
|
||||
|
||||
Column {
|
||||
// Use blog name from site metadata, truncate to ~20 chars
|
||||
val displayName = siteData?.title?.let {
|
||||
if (it.length > 20) it.take(20) + "\u2026" else it
|
||||
} ?: activeAccount?.name ?: "Swoosh"
|
||||
Text(
|
||||
text = activeAccount?.name ?: "Swoosh",
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (activeAccount != null) {
|
||||
Text(
|
||||
text = activeAccount!!.blogUrl
|
||||
.removePrefix("https://")
|
||||
.removePrefix("http://")
|
||||
.removeSuffix("/"),
|
||||
text = activeAccount!!.blogUrl.toDisplayUrl(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
|
@ -238,7 +263,7 @@ fun FeedScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (accounts.size > 1 || accounts.isNotEmpty()) {
|
||||
if (accounts.size > 1) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = "Switch account",
|
||||
|
|
@ -305,20 +330,17 @@ fun FeedScreen(
|
|||
)
|
||||
}
|
||||
|
||||
// Active tag filter bar
|
||||
if (state.activeTagFilter != null) {
|
||||
FilterChip(
|
||||
onClick = { viewModel.clearTagFilter() },
|
||||
label = { Text("#${state.activeTagFilter}") },
|
||||
selected = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(Icons.Default.Close, contentDescription = "Clear filter", modifier = Modifier.size(16.dp))
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
// Tag filter chips
|
||||
AnimatedVisibility(
|
||||
visible = !isSearchActive && tagsEnabled && popularTags.isNotEmpty(),
|
||||
enter = fadeIn(SwooshMotion.quick()) + expandVertically(),
|
||||
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically()
|
||||
) {
|
||||
TagFilterChipsBar(
|
||||
tags = popularTags,
|
||||
activeTagFilter = state.activeTagFilter,
|
||||
onTagSelected = { viewModel.filterByTag(it) },
|
||||
onClearFilter = { viewModel.clearTagFilter() }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -567,7 +589,7 @@ fun FeedScreen(
|
|||
onEdit = { onEditPost(post) },
|
||||
onDelete = { postPendingDelete = post },
|
||||
onTogglePin = { viewModel.toggleFeatured(post) },
|
||||
onTagClick = { tag -> viewModel.filterByTag(tag) },
|
||||
onTagClick = { tag -> if (tagsEnabled) viewModel.filterByTag(tag) },
|
||||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
}
|
||||
|
|
@ -593,7 +615,7 @@ fun FeedScreen(
|
|||
onEdit = { onEditPost(post) },
|
||||
onDelete = { postPendingDelete = post },
|
||||
onTogglePin = { viewModel.toggleFeatured(post) },
|
||||
onTagClick = { tag -> viewModel.filterByTag(tag) },
|
||||
onTagClick = { tag -> if (tagsEnabled) viewModel.filterByTag(tag) },
|
||||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
}
|
||||
|
|
@ -746,6 +768,12 @@ fun FilterChipsBar(
|
|||
activeFilter: PostFilter,
|
||||
onFilterSelected: (PostFilter) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val newsletterEnabled = remember {
|
||||
com.swoosh.microblog.data.NewsletterPreferences(context).isNewsletterEnabled()
|
||||
}
|
||||
val sentColor = Color(0xFF6A1B9A) // magenta
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -754,19 +782,29 @@ fun FilterChipsBar(
|
|||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
PostFilter.values().forEach { filter ->
|
||||
// Only show SENT chip when newsletter is enabled
|
||||
if (filter == PostFilter.SENT && !newsletterEnabled) return@forEach
|
||||
|
||||
val selected = filter == activeFilter
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = if (selected)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surface,
|
||||
targetValue = when {
|
||||
selected && filter == PostFilter.SENT -> sentColor.copy(alpha = 0.2f)
|
||||
selected -> MaterialTheme.colorScheme.primaryContainer
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
},
|
||||
animationSpec = SwooshMotion.quick(),
|
||||
label = "chipColor"
|
||||
)
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onFilterSelected(filter) },
|
||||
label = { Text(filter.displayName) },
|
||||
label = {
|
||||
Text(
|
||||
filter.displayName,
|
||||
color = if (selected && filter == PostFilter.SENT) sentColor
|
||||
else Color.Unspecified
|
||||
)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = containerColor
|
||||
)
|
||||
|
|
@ -775,6 +813,58 @@ fun FilterChipsBar(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TagFilterChipsBar(
|
||||
tags: List<GhostTagFull>,
|
||||
activeTagFilter: String?,
|
||||
onTagSelected: (String) -> Unit,
|
||||
onClearFilter: () -> Unit
|
||||
) {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// "All tags" chip
|
||||
item {
|
||||
val isAllSelected = activeTagFilter == null
|
||||
FilterChip(
|
||||
selected = isAllSelected,
|
||||
onClick = { onClearFilter() },
|
||||
label = { Text("All tags") },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Tag chips
|
||||
items(tags, key = { it.id ?: it.name }) { tag ->
|
||||
val isSelected = activeTagFilter != null && activeTagFilter.equals(tag.name, ignoreCase = true)
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (isSelected) onClearFilter() else onTagSelected(tag.name)
|
||||
},
|
||||
label = {
|
||||
val postCount = tag.count?.posts
|
||||
if (postCount != null) {
|
||||
Text("${tag.name} ($postCount)")
|
||||
} else {
|
||||
Text(tag.name)
|
||||
}
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SortButton(
|
||||
currentSort: SortOrder,
|
||||
|
|
@ -1087,10 +1177,7 @@ fun AccountListItem(
|
|||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
account.blogUrl
|
||||
.removePrefix("https://")
|
||||
.removePrefix("http://")
|
||||
.removeSuffix("/"),
|
||||
account.blogUrl.toDisplayUrl(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
|
@ -1352,11 +1439,6 @@ fun PostCardContent(
|
|||
var expanded by remember { mutableStateOf(false) }
|
||||
var showContextMenu by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val displayText = if (expanded || post.textContent.length <= 280) {
|
||||
post.textContent
|
||||
} else {
|
||||
post.textContent.take(280) + "..."
|
||||
}
|
||||
|
||||
val isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE
|
||||
val hasShareableUrl = !post.slug.isNullOrBlank() || !post.url.isNullOrBlank()
|
||||
|
|
@ -1415,7 +1497,8 @@ fun PostCardContent(
|
|||
// Content -- the star of the show
|
||||
if (highlightQuery != null && highlightQuery.isNotBlank()) {
|
||||
HighlightedText(
|
||||
text = displayText,
|
||||
text = if (expanded || post.textContent.length <= 280) post.textContent
|
||||
else post.textContent.take(280) + "...",
|
||||
query = highlightQuery,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 8
|
||||
)
|
||||
|
|
@ -1483,6 +1566,18 @@ fun PostCardContent(
|
|||
}
|
||||
}
|
||||
|
||||
// Video player (compact)
|
||||
if (post.videoUrl != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
VideoPlayer(url = post.videoUrl, compact = true)
|
||||
}
|
||||
|
||||
// Audio player (compact)
|
||||
if (post.audioUrl != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
AudioPlayer(url = post.audioUrl)
|
||||
}
|
||||
|
||||
// Link preview
|
||||
if (post.linkUrl != null && post.linkTitle != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
|
@ -1519,23 +1614,28 @@ fun PostCardContent(
|
|||
}
|
||||
}
|
||||
|
||||
// Hashtag tags (bold colored text, not chips)
|
||||
// File attachment
|
||||
if (post.fileUrl != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FileAttachmentCard(
|
||||
fileUrl = post.fileUrl,
|
||||
fileName = post.fileName ?: "File"
|
||||
)
|
||||
}
|
||||
|
||||
// Tags display
|
||||
if (post.tags.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
post.tags.forEach { tag ->
|
||||
Text(
|
||||
text = "#$tag",
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable { onTagClick(tag) }
|
||||
)
|
||||
Text(
|
||||
text = post.tags.joinToString(" \u00B7 ") { "#$it" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable {
|
||||
post.tags.firstOrNull()?.let { onTagClick(it) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Queue status
|
||||
|
|
@ -1543,6 +1643,7 @@ fun PostCardContent(
|
|||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val queueLabel = when (post.queueStatus) {
|
||||
QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload"
|
||||
QueueStatus.QUEUED_EMAIL_ONLY -> "Pending email send"
|
||||
QueueStatus.UPLOADING -> "Uploading..."
|
||||
QueueStatus.FAILED -> "Upload failed"
|
||||
else -> ""
|
||||
|
|
@ -1553,7 +1654,7 @@ fun PostCardContent(
|
|||
label = queueLabel,
|
||||
isUploading = isUploading
|
||||
)
|
||||
if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED) {
|
||||
if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED || post.queueStatus == QueueStatus.QUEUED_EMAIL_ONLY) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
TextButton(onClick = onCancelQueue) {
|
||||
Text("Cancel", style = MaterialTheme.typography.labelSmall)
|
||||
|
|
@ -1568,12 +1669,15 @@ fun PostCardContent(
|
|||
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
|
||||
PostStats.fromFeedPost(post)
|
||||
}
|
||||
val isSent = post.status == "sent" || post.emailOnly
|
||||
val statusLabel = when {
|
||||
post.queueStatus != QueueStatus.NONE -> "Pending"
|
||||
isSent -> "Sent"
|
||||
else -> post.status.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
val statusColor = when {
|
||||
post.queueStatus != QueueStatus.NONE -> Color(0xFFE65100)
|
||||
isSent -> Color(0xFF6A1B9A)
|
||||
post.status == "published" -> Color(0xFF2E7D32)
|
||||
post.status == "scheduled" -> Color(0xFF1565C0)
|
||||
else -> Color(0xFF7B1FA2)
|
||||
|
|
@ -1582,19 +1686,28 @@ fun PostCardContent(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(statusColor)
|
||||
)
|
||||
if (isSent) {
|
||||
Icon(
|
||||
Icons.Default.Email,
|
||||
contentDescription = "Sent",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = statusColor
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(statusColor)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = statusLabel,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
|
||||
color = statusColor
|
||||
)
|
||||
Text(
|
||||
text = "·",
|
||||
text = "\u00B7",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
|
@ -1635,8 +1748,35 @@ fun PostCardContent(
|
|||
)
|
||||
}
|
||||
|
||||
// Share action (copies link to clipboard)
|
||||
if (isPublished && hasShareableUrl) {
|
||||
// Share / Copy content action
|
||||
if (isSent) {
|
||||
// For sent (email-only) posts, show "Copy content" instead of "Share"
|
||||
val copyContext = LocalContext.current
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.clickable {
|
||||
val clipboard = copyContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("Post content", post.textContent))
|
||||
snackbarHostState?.let { host ->
|
||||
coroutineScope.launch {
|
||||
host.showSnackbar("Content copied to clipboard")
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy content",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Copy",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else if (isPublished && hasShareableUrl) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.clickable {
|
||||
|
|
@ -2089,8 +2229,10 @@ fun buildHighlightedString(
|
|||
|
||||
@Composable
|
||||
fun StatusBadge(post: FeedPost) {
|
||||
val isSent = post.status == "sent" || post.emailOnly
|
||||
val (label, containerColor, labelColor) = when {
|
||||
post.queueStatus != QueueStatus.NONE -> Triple("Pending", Color(0xFFFFF3E0), Color(0xFFE65100))
|
||||
isSent -> Triple("Sent", Color(0xFFF3E5F5), Color(0xFF6A1B9A))
|
||||
post.status == "published" -> Triple("Published", Color(0xFFE8F5E9), Color(0xFF2E7D32))
|
||||
post.status == "scheduled" -> Triple("Scheduled", Color(0xFFE3F2FD), Color(0xFF1565C0))
|
||||
else -> Triple("Draft", Color(0xFFF3E5F5), Color(0xFF7B1FA2))
|
||||
|
|
@ -2108,3 +2250,57 @@ fun StatusBadge(post: FeedPost) {
|
|||
border = null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Card displaying a file attachment in Feed/Detail screens.
|
||||
* Tapping opens the file URL in an external viewer.
|
||||
*/
|
||||
@Composable
|
||||
fun FileAttachmentCard(
|
||||
fileUrl: String,
|
||||
fileName: String
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val iconTint = com.swoosh.microblog.ui.components.fileTypeColor(fileName = fileName)
|
||||
|
||||
OutlinedCard(
|
||||
onClick = {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, android.net.Uri.parse(fileUrl))
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
// No handler available for this file type
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.InsertDriveFile,
|
||||
contentDescription = "File attachment",
|
||||
modifier = Modifier.size(28.dp),
|
||||
tint = iconTint
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = fileName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "Tap to download",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import com.swoosh.microblog.data.AccountManager
|
|||
import com.swoosh.microblog.data.CredentialsManager
|
||||
import com.swoosh.microblog.data.FeedPreferences
|
||||
import com.swoosh.microblog.data.HashtagParser
|
||||
import com.swoosh.microblog.data.TagsPreferences
|
||||
import com.swoosh.microblog.data.api.ApiClient
|
||||
import com.swoosh.microblog.data.db.Converters
|
||||
import com.swoosh.microblog.data.model.*
|
||||
import com.swoosh.microblog.data.repository.PostRepository
|
||||
import com.swoosh.microblog.data.repository.TagRepository
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
|
|
@ -36,9 +38,12 @@ data class SnackbarEvent(
|
|||
|
||||
class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val gson = Gson()
|
||||
private val accountManager = AccountManager(application)
|
||||
private var repository = PostRepository(application)
|
||||
private var tagRepository = TagRepository(application)
|
||||
private val feedPreferences = FeedPreferences(application)
|
||||
private val tagsPreferences = TagsPreferences(application)
|
||||
private val searchHistoryManager = SearchHistoryManager(application)
|
||||
|
||||
private val _uiState = MutableStateFlow(FeedUiState())
|
||||
|
|
@ -71,6 +76,12 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
private val _recentSearches = MutableStateFlow<List<String>>(emptyList())
|
||||
val recentSearches: StateFlow<List<String>> = _recentSearches.asStateFlow()
|
||||
|
||||
private val _popularTags = MutableStateFlow<List<GhostTagFull>>(emptyList())
|
||||
val popularTags: StateFlow<List<GhostTagFull>> = _popularTags.asStateFlow()
|
||||
|
||||
private val _tagsEnabled = MutableStateFlow(tagsPreferences.isTagsEnabled())
|
||||
val tagsEnabled: StateFlow<Boolean> = _tagsEnabled.asStateFlow()
|
||||
|
||||
private val _accounts = MutableStateFlow<List<GhostAccount>>(emptyList())
|
||||
val accounts: StateFlow<List<GhostAccount>> = _accounts.asStateFlow()
|
||||
|
||||
|
|
@ -237,8 +248,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
accountManager.setActiveAccount(accountId)
|
||||
ApiClient.reset()
|
||||
|
||||
// Re-create repository to pick up new account
|
||||
// Re-create repositories to pick up new account
|
||||
repository = PostRepository(getApplication())
|
||||
tagRepository = TagRepository(getApplication())
|
||||
|
||||
refreshAccountsList()
|
||||
|
||||
|
|
@ -296,6 +308,25 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
val sort = _sortOrder.value
|
||||
val tagFilter = _uiState.value.activeTagFilter
|
||||
|
||||
// Fetch popular tags in parallel (only when tags feature is enabled)
|
||||
val tagsOn = tagsPreferences.isTagsEnabled()
|
||||
_tagsEnabled.value = tagsOn
|
||||
if (tagsOn) {
|
||||
launch {
|
||||
tagRepository.fetchTags().fold(
|
||||
onSuccess = { tags ->
|
||||
_popularTags.value = tags
|
||||
.sortedByDescending { it.count?.posts ?: 0 }
|
||||
.take(10)
|
||||
},
|
||||
onFailure = { /* silently ignore tag fetch failures */ }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_popularTags.value = emptyList()
|
||||
_uiState.update { it.copy(activeTagFilter = null) }
|
||||
}
|
||||
|
||||
repository.fetchPosts(page = 1, filter = filter, sortOrder = sort, tagFilter = tagFilter).fold(
|
||||
onSuccess = { response ->
|
||||
remotePosts = response.posts.map { it.toFeedPost() }
|
||||
|
|
@ -312,6 +343,15 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
}
|
||||
}
|
||||
|
||||
fun refreshTagsEnabled() {
|
||||
val enabled = tagsPreferences.isTagsEnabled()
|
||||
_tagsEnabled.value = enabled
|
||||
if (!enabled) {
|
||||
_popularTags.value = emptyList()
|
||||
_uiState.update { it.copy(activeTagFilter = null) }
|
||||
}
|
||||
}
|
||||
|
||||
fun filterByTag(tag: String) {
|
||||
_uiState.update { it.copy(activeTagFilter = tag) }
|
||||
refresh()
|
||||
|
|
@ -505,18 +545,74 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
_uiState.update { it.copy(posts = sorted) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed mobiledoc card data. Single-parse result for all card types.
|
||||
*/
|
||||
private data class MobiledocCards(
|
||||
val imageUrls: List<String> = emptyList(),
|
||||
val videoUrl: String? = null,
|
||||
val audioUrl: String? = null,
|
||||
val fileUrl: String? = null,
|
||||
val fileName: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Parses mobiledoc JSON once and extracts all card data (images, video, audio, file).
|
||||
*/
|
||||
private fun parseMobiledocCards(mobiledoc: String?): MobiledocCards {
|
||||
if (mobiledoc == null) return MobiledocCards()
|
||||
return try {
|
||||
val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards") ?: return MobiledocCards()
|
||||
val imageUrls = mutableListOf<String>()
|
||||
var videoUrl: String? = null
|
||||
var audioUrl: String? = null
|
||||
var fileUrl: String? = null
|
||||
var fileName: String? = null
|
||||
for (card in cards) {
|
||||
val cardArray = card.asJsonArray
|
||||
if (cardArray.size() < 2) continue
|
||||
val cardData = cardArray[1].asJsonObject
|
||||
when (cardArray[0].asString) {
|
||||
"image" -> cardData.get("src")?.asString?.let { imageUrls.add(it) }
|
||||
"video" -> if (videoUrl == null) videoUrl = cardData.get("src")?.asString
|
||||
"audio" -> if (audioUrl == null) audioUrl = cardData.get("src")?.asString
|
||||
"file" -> if (fileUrl == null) {
|
||||
fileUrl = cardData.get("src")?.asString
|
||||
fileName = cardData.get("fileName")?.asString ?: "file"
|
||||
}
|
||||
}
|
||||
}
|
||||
MobiledocCards(imageUrls, videoUrl, audioUrl, fileUrl, fileName)
|
||||
} catch (e: Exception) {
|
||||
MobiledocCards()
|
||||
}
|
||||
}
|
||||
|
||||
private val imgSrcRegex = Regex("""<img[^>]+src\s*=\s*["']([^"']+)["']""", RegexOption.IGNORE_CASE)
|
||||
|
||||
private fun GhostPost.toFeedPost(): FeedPost {
|
||||
val imageUrls = extractImageUrlsFromMobiledoc(mobiledoc)
|
||||
// Use feature_image as primary, then add mobiledoc images (avoiding duplicates)
|
||||
val mobiledocCards = parseMobiledocCards(mobiledoc)
|
||||
// Use feature_image as primary, then add mobiledoc images, then HTML images (avoiding duplicates)
|
||||
val allImages = mutableListOf<String>()
|
||||
if (feature_image != null) {
|
||||
allImages.add(feature_image)
|
||||
}
|
||||
for (url in imageUrls) {
|
||||
for (url in mobiledocCards.imageUrls) {
|
||||
if (url !in allImages) {
|
||||
allImages.add(url)
|
||||
}
|
||||
}
|
||||
// Fallback: extract images from HTML content
|
||||
if (allImages.isEmpty() && html != null) {
|
||||
imgSrcRegex.findAll(html).forEach { match ->
|
||||
val url = match.groupValues[1]
|
||||
if (url !in allImages) {
|
||||
allImages.add(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
val isEmailOnly = status == "sent" || email_only == true
|
||||
return FeedPost(
|
||||
ghostId = id,
|
||||
slug = slug,
|
||||
|
|
@ -527,6 +623,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
imageUrl = allImages.firstOrNull(),
|
||||
imageAlt = feature_image_alt,
|
||||
imageUrls = allImages,
|
||||
videoUrl = mobiledocCards.videoUrl,
|
||||
audioUrl = mobiledocCards.audioUrl,
|
||||
linkUrl = null,
|
||||
linkTitle = null,
|
||||
linkDescription = null,
|
||||
|
|
@ -537,34 +635,16 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
publishedAt = published_at,
|
||||
createdAt = created_at,
|
||||
updatedAt = updated_at,
|
||||
isLocal = false
|
||||
isLocal = false,
|
||||
fileUrl = mobiledocCards.fileUrl,
|
||||
fileName = mobiledocCards.fileName,
|
||||
emailOnly = isEmailOnly
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts image URLs from Ghost mobiledoc JSON.
|
||||
* Image cards have the format: ["image", {"src": "url"}]
|
||||
*/
|
||||
private fun extractImageUrlsFromMobiledoc(mobiledoc: String?): List<String> {
|
||||
if (mobiledoc == null) return emptyList()
|
||||
return try {
|
||||
val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards") ?: return emptyList()
|
||||
cards.mapNotNull { card ->
|
||||
val cardArray = card.asJsonArray
|
||||
if (cardArray.size() >= 2 && cardArray[0].asString == "image") {
|
||||
val cardData = cardArray[1].asJsonObject
|
||||
cardData.get("src")?.asString
|
||||
} else null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun LocalPost.toFeedPost(): FeedPost {
|
||||
val tagNames: List<String> = try {
|
||||
Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
|
||||
gson.fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
|
@ -586,6 +666,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
imageUrl = allImageUrls.firstOrNull(),
|
||||
imageAlt = imageAlt,
|
||||
imageUrls = allImageUrls,
|
||||
videoUrl = uploadedVideoUrl ?: videoUri,
|
||||
audioUrl = uploadedAudioUrl ?: audioUri,
|
||||
linkUrl = linkUrl,
|
||||
linkTitle = linkTitle,
|
||||
linkDescription = linkDescription,
|
||||
|
|
@ -597,7 +679,10 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
createdAt = null,
|
||||
updatedAt = null,
|
||||
isLocal = true,
|
||||
queueStatus = queueStatus
|
||||
queueStatus = queueStatus,
|
||||
fileUrl = uploadedFileUrl ?: fileUri,
|
||||
fileName = fileName,
|
||||
emailOnly = emailOnly
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,437 @@
|
|||
package com.swoosh.microblog.ui.members
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.swoosh.microblog.data.model.GhostMember
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MemberDetailScreen(
|
||||
member: GhostMember,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Member") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Header: large avatar, name, email
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
DetailAvatar(
|
||||
avatarUrl = member.avatar_image,
|
||||
name = member.name ?: member.email ?: "?",
|
||||
modifier = Modifier.size(80.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = member.name ?: "Unknown",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (member.email != null) {
|
||||
Text(
|
||||
text = member.email,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Quick stat tiles: status, open rate, emails
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
QuickStatCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = (member.status ?: "free").replaceFirstChar { it.uppercase() },
|
||||
label = "Status",
|
||||
icon = if (member.status == "paid") Icons.Default.Diamond else Icons.Default.Person
|
||||
)
|
||||
QuickStatCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = member.email_open_rate?.let { "${it.toInt()}%" } ?: "N/A",
|
||||
label = "Open rate",
|
||||
icon = Icons.Default.MailOutline
|
||||
)
|
||||
QuickStatCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = "${member.email_count ?: 0}",
|
||||
label = "Emails",
|
||||
icon = Icons.Default.Email
|
||||
)
|
||||
}
|
||||
|
||||
// Subscription section (paid only)
|
||||
val activeSubscriptions = member.subscriptions?.filter { it.status == "active" }
|
||||
if (!activeSubscriptions.isNullOrEmpty()) {
|
||||
Text(
|
||||
"Subscription",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
activeSubscriptions.forEach { sub ->
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
sub.tier?.name?.let {
|
||||
DetailRow("Tier", it)
|
||||
}
|
||||
sub.price?.let { price ->
|
||||
val amount = price.amount?.let { "$${it / 100.0}" } ?: "N/A"
|
||||
val interval = price.interval ?: ""
|
||||
DetailRow("Price", "$amount / $interval")
|
||||
price.currency?.let { DetailRow("Currency", it.uppercase()) }
|
||||
}
|
||||
sub.status?.let {
|
||||
DetailRow("Status", it.replaceFirstChar { c -> c.uppercase() })
|
||||
}
|
||||
sub.start_date?.let {
|
||||
DetailRow("Started", formatDate(it))
|
||||
}
|
||||
sub.current_period_end?.let {
|
||||
DetailRow("Renews", formatDate(it))
|
||||
}
|
||||
if (sub.cancel_at_period_end == true) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
"Cancels at end of period",
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Activity section
|
||||
Text(
|
||||
"Activity",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
member.created_at?.let {
|
||||
DetailRow("Joined", formatDate(it))
|
||||
}
|
||||
member.last_seen_at?.let {
|
||||
DetailRow("Last seen", formatRelativeTimeLong(it))
|
||||
}
|
||||
member.geolocation?.let {
|
||||
if (it.isNotBlank()) {
|
||||
DetailRow("Location", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Newsletters section
|
||||
val newsletters = member.newsletters
|
||||
if (!newsletters.isNullOrEmpty()) {
|
||||
Text(
|
||||
"Newsletters",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
newsletters.forEach { newsletter ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = true,
|
||||
onCheckedChange = null, // read-only
|
||||
enabled = false
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = newsletter.name ?: newsletter.slug ?: newsletter.id,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Labels section
|
||||
val labels = member.labels
|
||||
if (!labels.isNullOrEmpty()) {
|
||||
Text(
|
||||
"Labels",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
labels.forEach { label ->
|
||||
@Suppress("DEPRECATION")
|
||||
AssistChip(
|
||||
onClick = { },
|
||||
label = { Text(label.name) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Label,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Email activity
|
||||
val emailCount = member.email_count ?: 0
|
||||
val emailOpened = member.email_opened_count ?: 0
|
||||
if (emailCount > 0) {
|
||||
Text(
|
||||
"Email Activity",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
DetailRow("Emails sent", "$emailCount")
|
||||
DetailRow("Emails opened", "$emailOpened")
|
||||
val openRate = if (emailCount > 0) emailOpened.toFloat() / emailCount else 0f
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
"Open rate",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
"${(openRate * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { openRate.coerceIn(0f, 1f) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp),
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note
|
||||
if (!member.note.isNullOrBlank()) {
|
||||
Text(
|
||||
"Note",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = member.note,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickStatCard(
|
||||
modifier: Modifier = Modifier,
|
||||
value: String,
|
||||
label: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector
|
||||
) {
|
||||
ElevatedCard(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailAvatar(
|
||||
avatarUrl: String?,
|
||||
name: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (avatarUrl != null) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(avatarUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "Avatar for $name",
|
||||
modifier = modifier.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
val initial = name.firstOrNull()?.uppercase() ?: "?"
|
||||
val colors = listOf(
|
||||
0xFF6750A4, 0xFF00796B, 0xFFD32F2F, 0xFF1976D2, 0xFFF57C00
|
||||
)
|
||||
val colorIndex = name.hashCode().let { Math.abs(it) % colors.size }
|
||||
val bgColor = androidx.compose.ui.graphics.Color(colors[colorIndex])
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.background(bgColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = initial,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = androidx.compose.ui.graphics.Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(isoDate: String): String {
|
||||
return try {
|
||||
val instant = Instant.parse(isoDate)
|
||||
val localDate = instant.atZone(java.time.ZoneId.systemDefault()).toLocalDate()
|
||||
val formatter = java.time.format.DateTimeFormatter.ofPattern("MMM d, yyyy")
|
||||
localDate.format(formatter)
|
||||
} catch (e: Exception) {
|
||||
isoDate
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatRelativeTimeLong(isoDate: String): String {
|
||||
return try {
|
||||
val instant = Instant.parse(isoDate)
|
||||
val now = Instant.now()
|
||||
val duration = Duration.between(instant, now)
|
||||
|
||||
when {
|
||||
duration.toMinutes() < 1 -> "Just now"
|
||||
duration.toHours() < 1 -> "${duration.toMinutes()} minutes ago"
|
||||
duration.toDays() < 1 -> "${duration.toHours()} hours ago"
|
||||
duration.toDays() < 7 -> "${duration.toDays()} days ago"
|
||||
duration.toDays() < 30 -> "${duration.toDays() / 7} weeks ago"
|
||||
duration.toDays() < 365 -> "${duration.toDays() / 30} months ago"
|
||||
else -> "${duration.toDays() / 365} years ago"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
isoDate
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,385 @@
|
|||
package com.swoosh.microblog.ui.members
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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 coil.request.ImageRequest
|
||||
import com.swoosh.microblog.data.model.GhostMember
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MembersScreen(
|
||||
viewModel: MembersViewModel = viewModel(),
|
||||
onMemberClick: (GhostMember) -> Unit = {},
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Trigger load more when near the bottom
|
||||
val shouldLoadMore = remember {
|
||||
derivedStateOf {
|
||||
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
lastVisibleItem >= state.members.size - 3 && state.hasMore && !state.isLoadingMore
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldLoadMore.value) {
|
||||
if (shouldLoadMore.value) {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Members (${state.totalCount})") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// Search field
|
||||
OutlinedTextField(
|
||||
value = state.searchQuery,
|
||||
onValueChange = { viewModel.search(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text("Search members...") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (state.searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.search("") }) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Clear")
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Filter row
|
||||
SingleChoiceSegmentedButtonRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
MemberFilter.entries.forEachIndexed { index, filter ->
|
||||
SegmentedButton(
|
||||
selected = state.filter == filter,
|
||||
onClick = { viewModel.updateFilter(filter) },
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = MemberFilter.entries.size
|
||||
)
|
||||
) {
|
||||
Text(filter.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
when {
|
||||
state.isLoading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
state.error != null && state.members.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.ErrorOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = state.error ?: "Failed to load members",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = { viewModel.loadMembers() }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.members.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No members found",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(
|
||||
items = state.members,
|
||||
key = { it.id }
|
||||
) { member ->
|
||||
MemberRow(
|
||||
member = member,
|
||||
onClick = { onMemberClick(member) }
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isLoadingMore) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberRow(
|
||||
member: GhostMember,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val isNew = member.created_at?.let {
|
||||
try {
|
||||
val created = Instant.parse(it)
|
||||
Duration.between(created, Instant.now()).toDays() < 7
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
|
||||
val isPaid = member.status == "paid"
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Avatar
|
||||
MemberAvatar(
|
||||
avatarUrl = member.avatar_image,
|
||||
name = member.name ?: member.email ?: "?",
|
||||
modifier = Modifier.size(44.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Name, email, badges
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = member.name ?: member.email ?: "Unknown",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
if (isPaid) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = MaterialTheme.shapes.extraSmall
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Diamond,
|
||||
contentDescription = "Paid",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
"PAID",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isNew) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = MaterialTheme.shapes.extraSmall
|
||||
) {
|
||||
Text(
|
||||
"NEW",
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (member.email != null && member.name != null) {
|
||||
Text(
|
||||
text = member.email,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
// Open rate bar
|
||||
val openRate = member.email_open_rate
|
||||
if (openRate != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (openRate / 100f).toFloat().coerceIn(0f, 1f) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(4.dp),
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "${openRate.toInt()}%",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Relative time
|
||||
member.last_seen_at?.let { lastSeen ->
|
||||
Text(
|
||||
text = formatRelativeTime(lastSeen),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberAvatar(
|
||||
avatarUrl: String?,
|
||||
name: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (avatarUrl != null) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(avatarUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "Avatar for $name",
|
||||
modifier = modifier.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
val initial = name.firstOrNull()?.uppercase() ?: "?"
|
||||
val colors = listOf(
|
||||
0xFF6750A4, 0xFF00796B, 0xFFD32F2F, 0xFF1976D2, 0xFFF57C00
|
||||
)
|
||||
val colorIndex = name.hashCode().let { Math.abs(it) % colors.size }
|
||||
val bgColor = androidx.compose.ui.graphics.Color(colors[colorIndex])
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.background(bgColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = initial,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = androidx.compose.ui.graphics.Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatRelativeTime(isoDate: String): String {
|
||||
return try {
|
||||
val instant = Instant.parse(isoDate)
|
||||
val now = Instant.now()
|
||||
val duration = Duration.between(instant, now)
|
||||
|
||||
when {
|
||||
duration.toMinutes() < 1 -> "now"
|
||||
duration.toHours() < 1 -> "${duration.toMinutes()}m"
|
||||
duration.toDays() < 1 -> "${duration.toHours()}h"
|
||||
duration.toDays() < 7 -> "${duration.toDays()}d"
|
||||
duration.toDays() < 30 -> "${duration.toDays() / 7}w"
|
||||
duration.toDays() < 365 -> "${duration.toDays() / 30}mo"
|
||||
else -> "${duration.toDays() / 365}y"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package com.swoosh.microblog.ui.members
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.swoosh.microblog.data.model.GhostMember
|
||||
import com.swoosh.microblog.data.repository.MemberRepository
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class MemberFilter(val displayName: String, val ghostFilter: String?) {
|
||||
ALL("All", null),
|
||||
FREE("Free", "status:free"),
|
||||
PAID("Paid", "status:paid")
|
||||
}
|
||||
|
||||
data class MembersUiState(
|
||||
val members: List<GhostMember> = emptyList(),
|
||||
val totalCount: Int = 0,
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val hasMore: Boolean = false,
|
||||
val currentPage: Int = 1,
|
||||
val filter: MemberFilter = MemberFilter.ALL,
|
||||
val searchQuery: String = "",
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class MembersViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val repository = MemberRepository(application)
|
||||
|
||||
private val _uiState = MutableStateFlow(MembersUiState())
|
||||
val uiState: StateFlow<MembersUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
init {
|
||||
loadMembers()
|
||||
}
|
||||
|
||||
fun loadMembers() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null, currentPage = 1) }
|
||||
|
||||
val filter = buildFilter()
|
||||
val result = repository.fetchMembers(page = 1, limit = 15, filter = filter)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
members = response.members,
|
||||
totalCount = response.meta?.pagination?.total ?: response.members.size,
|
||||
hasMore = response.meta?.pagination?.next != null,
|
||||
currentPage = 1,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = e.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val state = _uiState.value
|
||||
if (state.isLoadingMore || !state.hasMore) return
|
||||
|
||||
viewModelScope.launch {
|
||||
val nextPage = state.currentPage + 1
|
||||
_uiState.update { it.copy(isLoadingMore = true) }
|
||||
|
||||
val filter = buildFilter()
|
||||
val result = repository.fetchMembers(page = nextPage, limit = 15, filter = filter)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
members = it.members + response.members,
|
||||
hasMore = response.meta?.pagination?.next != null,
|
||||
currentPage = nextPage,
|
||||
isLoadingMore = false
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update {
|
||||
it.copy(isLoadingMore = false, error = e.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFilter(newFilter: MemberFilter) {
|
||||
if (newFilter == _uiState.value.filter) return
|
||||
_uiState.update { it.copy(filter = newFilter) }
|
||||
loadMembers()
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
_uiState.update { it.copy(searchQuery = query) }
|
||||
searchJob?.cancel()
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(300) // debounce
|
||||
loadMembers()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildFilter(): String? {
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
// Status filter
|
||||
_uiState.value.filter.ghostFilter?.let { parts.add(it) }
|
||||
|
||||
// Search filter
|
||||
val query = _uiState.value.searchQuery.trim()
|
||||
if (query.isNotEmpty()) {
|
||||
parts.add("name:~'$query',email:~'$query'")
|
||||
}
|
||||
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString("+")
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.animation.core.tween
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BarChart
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
|
|
@ -25,15 +26,21 @@ import androidx.navigation.compose.NavHost
|
|||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.swoosh.microblog.data.model.FeedPost
|
||||
import com.swoosh.microblog.data.model.GhostMember
|
||||
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.pages.PagesScreen
|
||||
import com.swoosh.microblog.ui.feed.FeedViewModel
|
||||
import com.swoosh.microblog.ui.members.MemberDetailScreen
|
||||
import com.swoosh.microblog.ui.members.MembersScreen
|
||||
import com.swoosh.microblog.ui.newsletter.NewsletterScreen
|
||||
import com.swoosh.microblog.ui.preview.PreviewScreen
|
||||
import com.swoosh.microblog.ui.settings.SettingsScreen
|
||||
import com.swoosh.microblog.ui.setup.SetupScreen
|
||||
import com.swoosh.microblog.ui.stats.StatsScreen
|
||||
import com.swoosh.microblog.ui.tags.TagsScreen
|
||||
import com.swoosh.microblog.ui.theme.ThemeViewModel
|
||||
|
||||
object Routes {
|
||||
|
|
@ -46,6 +53,11 @@ object Routes {
|
|||
const val STATS = "stats"
|
||||
const val PREVIEW = "preview"
|
||||
const val ADD_ACCOUNT = "add_account"
|
||||
const val PAGES = "pages"
|
||||
const val MEMBERS = "members"
|
||||
const val MEMBER_DETAIL = "member_detail"
|
||||
const val TAGS = "tags"
|
||||
const val NEWSLETTER = "newsletter"
|
||||
}
|
||||
|
||||
data class BottomNavItem(
|
||||
|
|
@ -56,12 +68,13 @@ data class BottomNavItem(
|
|||
|
||||
val bottomNavItems = listOf(
|
||||
BottomNavItem(Routes.FEED, "Home", Icons.Default.Home),
|
||||
BottomNavItem(Routes.NEWSLETTER, "Newsletter", Icons.Default.Email),
|
||||
BottomNavItem(Routes.STATS, "Stats", Icons.Default.BarChart),
|
||||
BottomNavItem(Routes.SETTINGS, "Settings", Icons.Default.Settings)
|
||||
)
|
||||
|
||||
/** Routes where the bottom navigation bar should be visible */
|
||||
private val bottomBarRoutes = setOf(Routes.FEED, Routes.STATS, Routes.SETTINGS)
|
||||
private val bottomBarRoutes = setOf(Routes.FEED, Routes.NEWSLETTER, Routes.STATS, Routes.SETTINGS)
|
||||
|
||||
@Composable
|
||||
fun SwooshNavGraph(
|
||||
|
|
@ -73,6 +86,7 @@ fun SwooshNavGraph(
|
|||
var selectedPost by remember { mutableStateOf<FeedPost?>(null) }
|
||||
var editPost by remember { mutableStateOf<FeedPost?>(null) }
|
||||
var previewHtml by remember { mutableStateOf("") }
|
||||
var selectedMember by remember { mutableStateOf<GhostMember?>(null) }
|
||||
|
||||
val feedViewModel: FeedViewModel = viewModel()
|
||||
|
||||
|
|
@ -95,6 +109,7 @@ fun SwooshNavGraph(
|
|||
onClick = {
|
||||
if (item.route == Routes.FEED) {
|
||||
feedViewModel.deactivateSearch()
|
||||
feedViewModel.refreshTagsEnabled()
|
||||
}
|
||||
navController.navigate(item.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
|
|
@ -255,6 +270,12 @@ fun SwooshNavGraph(
|
|||
navController.navigate(Routes.SETUP) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateToPages = {
|
||||
navController.navigate(Routes.PAGES)
|
||||
},
|
||||
onNavigateToTags = {
|
||||
navController.navigate(Routes.TAGS)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -266,7 +287,33 @@ fun SwooshNavGraph(
|
|||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { fadeOut(tween(150)) }
|
||||
) {
|
||||
StatsScreen()
|
||||
StatsScreen(
|
||||
onNavigateToMembers = {
|
||||
navController.navigate(Routes.MEMBERS)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.NEWSLETTER,
|
||||
enterTransition = { fadeIn(tween(200)) },
|
||||
exitTransition = { fadeOut(tween(150)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { fadeOut(tween(150)) }
|
||||
) {
|
||||
NewsletterScreen()
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.TAGS,
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
|
||||
exitTransition = { fadeOut(tween(150)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
|
||||
) {
|
||||
TagsScreen(
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
|
|
@ -300,6 +347,50 @@ fun SwooshNavGraph(
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.PAGES,
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
|
||||
exitTransition = { fadeOut(tween(150)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
|
||||
) {
|
||||
PagesScreen(
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.MEMBERS,
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
|
||||
exitTransition = { fadeOut(tween(150)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
|
||||
) {
|
||||
MembersScreen(
|
||||
onMemberClick = { member ->
|
||||
selectedMember = member
|
||||
navController.navigate(Routes.MEMBER_DETAIL)
|
||||
},
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.MEMBER_DETAIL,
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
|
||||
exitTransition = { fadeOut(tween(150)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
|
||||
) {
|
||||
val member = selectedMember
|
||||
if (member != null) {
|
||||
MemberDetailScreen(
|
||||
member = member,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,301 @@
|
|||
package com.swoosh.microblog.ui.newsletter
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
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.filled.Email
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.swoosh.microblog.data.NewsletterPreferences
|
||||
import com.swoosh.microblog.data.repository.PostRepository
|
||||
import com.swoosh.microblog.data.model.GhostNewsletter
|
||||
import com.swoosh.microblog.ui.animation.SwooshMotion
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NewsletterScreen() {
|
||||
val context = LocalContext.current
|
||||
val newsletterPreferences = remember { NewsletterPreferences(context) }
|
||||
var newsletterEnabled by remember { mutableStateOf(newsletterPreferences.isNewsletterEnabled()) }
|
||||
var validationStatus by remember { mutableStateOf<String?>(null) }
|
||||
var newsletters by remember { mutableStateOf<List<GhostNewsletter>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var subscriberCount by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
// Load newsletters on launch if enabled
|
||||
LaunchedEffect(newsletterEnabled) {
|
||||
if (newsletterEnabled) {
|
||||
isLoading = true
|
||||
try {
|
||||
val repository = PostRepository(context)
|
||||
val result = repository.fetchNewsletters()
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
newsletters = it
|
||||
validationStatus = "${it.size} newsletter(s) found"
|
||||
},
|
||||
onFailure = {
|
||||
validationStatus = "Could not load newsletters"
|
||||
}
|
||||
)
|
||||
// Fetch subscriber count
|
||||
val membersResult = repository.fetchSubscriberCount()
|
||||
membersResult.fold(
|
||||
onSuccess = { subscriberCount = it },
|
||||
onFailure = { /* ignore */ }
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
validationStatus = "Could not load newsletters"
|
||||
}
|
||||
isLoading = false
|
||||
} else {
|
||||
newsletters = emptyList()
|
||||
validationStatus = null
|
||||
subscriberCount = null
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Newsletter") }
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// Enable/Disable toggle
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Enable newsletter features",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = newsletterEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
newsletterEnabled = enabled
|
||||
newsletterPreferences.setNewsletterEnabled(enabled)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Show newsletter sending options when publishing posts",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Newsletter details (when enabled)
|
||||
AnimatedVisibility(
|
||||
visible = newsletterEnabled,
|
||||
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
|
||||
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (isLoading) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Subscriber count card
|
||||
if (subscriberCount != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.People,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = "$subscriberCount",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "Subscribers",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
// Newsletters list
|
||||
if (newsletters.isNotEmpty()) {
|
||||
Text(
|
||||
"Your Newsletters",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
newsletters.forEach { newsletter ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Email,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = newsletter.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (!newsletter.description.isNullOrBlank()) {
|
||||
Text(
|
||||
text = newsletter.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Status: ${newsletter.status}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Visibility: ${newsletter.visibility}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (validationStatus != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = validationStatus!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Info card
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "How it works",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "When enabled, the publish dialog offers options to send posts as newsletters or email-only content to your subscribers.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled state info
|
||||
AnimatedVisibility(
|
||||
visible = !newsletterEnabled,
|
||||
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
|
||||
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Email,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Newsletter features are disabled",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Enable to send posts as newsletters to your subscribers",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
393
app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt
Normal file
393
app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
package com.swoosh.microblog.ui.pages
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
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.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.swoosh.microblog.data.MobiledocBuilder
|
||||
import com.swoosh.microblog.data.model.GhostPage
|
||||
import com.swoosh.microblog.ui.components.ConfirmationDialog
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PagesScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: PagesViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
if (uiState.isEditing || uiState.isCreating) {
|
||||
PageEditorScreen(
|
||||
page = uiState.editingPage,
|
||||
isCreating = uiState.isCreating,
|
||||
isLoading = uiState.isLoading,
|
||||
error = uiState.error,
|
||||
blogUrl = viewModel.getBlogUrl(),
|
||||
onSave = { title, content, slug, status ->
|
||||
viewModel.savePage(title, content, slug, status)
|
||||
},
|
||||
onUpdate = { id, page ->
|
||||
viewModel.updatePage(id, page)
|
||||
},
|
||||
onCancel = { viewModel.cancelEditing() }
|
||||
)
|
||||
} else {
|
||||
PagesListScreen(
|
||||
pages = uiState.pages,
|
||||
isLoading = uiState.isLoading,
|
||||
error = uiState.error,
|
||||
onBack = onBack,
|
||||
onCreatePage = { viewModel.startCreating() },
|
||||
onEditPage = { page -> viewModel.startEditing(page) },
|
||||
onDeletePage = { id -> viewModel.deletePage(id) },
|
||||
onRefresh = { viewModel.loadPages() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PagesListScreen(
|
||||
pages: List<GhostPage>,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
onBack: () -> Unit,
|
||||
onCreatePage: () -> Unit,
|
||||
onEditPage: (GhostPage) -> Unit,
|
||||
onDeletePage: (String) -> Unit,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
var deletePageId by remember { mutableStateOf<String?>(null) }
|
||||
var deletePageTitle by remember { mutableStateOf("") }
|
||||
var expandedMenuPageId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Pages") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onCreatePage) {
|
||||
Icon(Icons.Default.Add, "New page")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
if (isLoading && pages.isEmpty()) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
} else if (pages.isEmpty()) {
|
||||
Text(
|
||||
text = "No pages yet.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(pages, key = { it.id ?: it.hashCode() }) { page ->
|
||||
Box {
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = { onEditPage(page) },
|
||||
onLongClick = { expandedMenuPageId = page.id }
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = page.title ?: "Untitled",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
if (!page.slug.isNullOrBlank()) {
|
||||
Text(
|
||||
text = "/${page.slug}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
StatusChip(status = page.status ?: "draft")
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expandedMenuPageId == page.id,
|
||||
onDismissRequest = { expandedMenuPageId = null }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit") },
|
||||
onClick = {
|
||||
expandedMenuPageId = null
|
||||
onEditPage(page)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
onClick = {
|
||||
expandedMenuPageId = null
|
||||
deletePageId = page.id
|
||||
deletePageTitle = page.title ?: "Untitled"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
Snackbar(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp),
|
||||
action = {
|
||||
TextButton(onClick = onRefresh) { Text("Retry") }
|
||||
}
|
||||
) {
|
||||
Text(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deletePageId != null) {
|
||||
ConfirmationDialog(
|
||||
title = "Delete Page?",
|
||||
message = "Delete \"$deletePageTitle\"? This cannot be undone.",
|
||||
confirmLabel = "Delete",
|
||||
onConfirm = {
|
||||
deletePageId?.let { onDeletePage(it) }
|
||||
deletePageId = null
|
||||
},
|
||||
onDismiss = { deletePageId = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusChip(status: String) {
|
||||
val (color, label) = when (status) {
|
||||
"published" -> Color(0xFF4CAF50) to "Published"
|
||||
else -> Color(0xFF9C27B0) to "Draft"
|
||||
}
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(label, style = MaterialTheme.typography.labelSmall) },
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
labelColor = color
|
||||
),
|
||||
border = BorderStroke(1.dp, color.copy(alpha = 0.5f))
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PageEditorScreen(
|
||||
page: GhostPage?,
|
||||
isCreating: Boolean,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
blogUrl: String?,
|
||||
onSave: (title: String, content: String, slug: String?, status: String) -> Unit,
|
||||
onUpdate: (id: String, page: GhostPage) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isNew = isCreating && page == null
|
||||
|
||||
var title by remember(page) { mutableStateOf(page?.title ?: "") }
|
||||
var content by remember(page) { mutableStateOf(page?.plaintext ?: "") }
|
||||
var slug by remember(page) { mutableStateOf(page?.slug ?: "") }
|
||||
var selectedStatus by remember(page) {
|
||||
mutableStateOf(if (page?.status == "published") "published" else "draft")
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(if (isNew) "New page" else "Edit page") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Cancel")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isNew) {
|
||||
onSave(title, content, slug.takeIf { it.isNotBlank() }, selectedStatus)
|
||||
} else {
|
||||
val mobiledoc = MobiledocBuilder.build(content)
|
||||
val updatedPage = GhostPage(
|
||||
title = title,
|
||||
mobiledoc = mobiledoc,
|
||||
slug = slug.takeIf { it.isNotBlank() },
|
||||
status = selectedStatus,
|
||||
updated_at = page?.updated_at
|
||||
)
|
||||
page?.id?.let { onUpdate(it, updatedPage) }
|
||||
}
|
||||
},
|
||||
enabled = title.isNotBlank() && !isLoading
|
||||
) {
|
||||
Icon(Icons.Default.Check, "Save")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
label = { Text("Title *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = content,
|
||||
onValueChange = { content = it },
|
||||
label = { Text("Content") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 8
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = slug,
|
||||
onValueChange = { slug = it },
|
||||
label = { Text("Slug (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Status radio group
|
||||
Text("Status", style = MaterialTheme.typography.titleSmall)
|
||||
Column(modifier = Modifier.selectableGroup()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = selectedStatus == "draft",
|
||||
onClick = { selectedStatus = "draft" },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedStatus == "draft",
|
||||
onClick = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Draft")
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = selectedStatus == "published",
|
||||
onClick = { selectedStatus = "published" },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedStatus == "published",
|
||||
onClick = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Publish")
|
||||
}
|
||||
}
|
||||
|
||||
// Open in browser for published pages
|
||||
if (!isNew && page?.status == "published" && !page.slug.isNullOrBlank() && blogUrl != null) {
|
||||
val pageUrl = "${blogUrl.removeSuffix("/")}/${page.slug}/"
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(pageUrl))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Open in browser")
|
||||
}
|
||||
}
|
||||
|
||||
// Revert to draft for published pages
|
||||
if (!isNew && page?.status == "published") {
|
||||
TextButton(
|
||||
onClick = { selectedStatus = "draft" },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Revert to draft")
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package com.swoosh.microblog.ui.pages
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.swoosh.microblog.data.MobiledocBuilder
|
||||
import com.swoosh.microblog.data.model.GhostPage
|
||||
import com.swoosh.microblog.data.repository.PageRepository
|
||||
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 PagesViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val repository = PageRepository(application)
|
||||
|
||||
private val _uiState = MutableStateFlow(PagesUiState())
|
||||
val uiState: StateFlow<PagesUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadPages()
|
||||
}
|
||||
|
||||
fun loadPages() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
repository.fetchPages().fold(
|
||||
onSuccess = { pages ->
|
||||
_uiState.update { it.copy(pages = pages, isLoading = false) }
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun savePage(title: String, content: String, slug: String?, status: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
val mobiledoc = MobiledocBuilder.build(content)
|
||||
val page = GhostPage(
|
||||
title = title,
|
||||
mobiledoc = mobiledoc,
|
||||
slug = slug?.takeIf { it.isNotBlank() },
|
||||
status = status
|
||||
)
|
||||
repository.createPage(page).fold(
|
||||
onSuccess = {
|
||||
_uiState.update { it.copy(isEditing = false, isCreating = false, editingPage = null) }
|
||||
loadPages()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePage(id: String, page: GhostPage) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
repository.updatePage(id, page).fold(
|
||||
onSuccess = {
|
||||
_uiState.update { it.copy(isEditing = false, isCreating = false, editingPage = null) }
|
||||
loadPages()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePage(id: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
repository.deletePage(id).fold(
|
||||
onSuccess = { loadPages() },
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun startEditing(page: GhostPage) {
|
||||
_uiState.update { it.copy(editingPage = page, isEditing = true, isCreating = false) }
|
||||
}
|
||||
|
||||
fun startCreating() {
|
||||
_uiState.update { it.copy(editingPage = null, isEditing = false, isCreating = true) }
|
||||
}
|
||||
|
||||
fun cancelEditing() {
|
||||
_uiState.update { it.copy(editingPage = null, isEditing = false, isCreating = false) }
|
||||
}
|
||||
|
||||
fun getBlogUrl(): String? = repository.getBlogUrl()
|
||||
}
|
||||
|
||||
data class PagesUiState(
|
||||
val pages: List<GhostPage> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val editingPage: GhostPage? = null,
|
||||
val isEditing: Boolean = false,
|
||||
val isCreating: Boolean = false
|
||||
)
|
||||
|
|
@ -1,24 +1,41 @@
|
|||
package com.swoosh.microblog.ui.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.BrightnessAuto
|
||||
import androidx.compose.material.icons.filled.DarkMode
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import com.swoosh.microblog.data.AccountManager
|
||||
import com.swoosh.microblog.data.toDisplayUrl
|
||||
import com.swoosh.microblog.data.TagsPreferences
|
||||
import com.swoosh.microblog.data.SiteMetadataCache
|
||||
import com.swoosh.microblog.data.api.ApiClient
|
||||
import com.swoosh.microblog.data.model.GhostAccount
|
||||
import com.swoosh.microblog.ui.animation.SwooshMotion
|
||||
import com.swoosh.microblog.ui.components.ConfirmationDialog
|
||||
import com.swoosh.microblog.ui.feed.AccountAvatar
|
||||
|
|
@ -30,11 +47,15 @@ import com.swoosh.microblog.ui.theme.ThemeViewModel
|
|||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
themeViewModel: ThemeViewModel? = null
|
||||
themeViewModel: ThemeViewModel? = null,
|
||||
onNavigateToPages: () -> Unit = {},
|
||||
onNavigateToTags: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val accountManager = remember { AccountManager(context) }
|
||||
val activeAccount = remember { accountManager.getActiveAccount() }
|
||||
val siteMetadataCache = remember { SiteMetadataCache(context) }
|
||||
val siteData = remember { activeAccount?.let { siteMetadataCache.get(it.id) } }
|
||||
|
||||
val currentThemeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
|
||||
|
||||
|
|
@ -75,6 +96,169 @@ fun SettingsScreen(
|
|||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
// --- Blog Info section ---
|
||||
if (siteData != null) {
|
||||
Text("Blog", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Site logo/icon or fallback initial
|
||||
val siteImageUrl = siteData.logo ?: siteData.icon
|
||||
if (siteImageUrl != null) {
|
||||
AsyncImage(
|
||||
model = siteImageUrl,
|
||||
contentDescription = "Site icon",
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
val initial = siteData.title?.firstOrNull()?.uppercase() ?: "?"
|
||||
val color = activeAccount?.let {
|
||||
Color(GhostAccount.colorForIndex(it.colorIndex))
|
||||
} ?: MaterialTheme.colorScheme.primary
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = initial,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = siteData.title ?: "Ghost Blog",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
if (!siteData.description.isNullOrBlank()) {
|
||||
Text(
|
||||
text = siteData.description!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// URL
|
||||
val siteUrl = siteData.url
|
||||
if (!siteUrl.isNullOrBlank()) {
|
||||
Text(
|
||||
text = siteUrl.toDisplayUrl(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Version + locale
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (siteData.version != null) {
|
||||
Text(
|
||||
text = "Ghost ${siteData.version}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (siteData.locale != null) {
|
||||
Text(
|
||||
text = "Locale: ${siteData.locale}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Version warning
|
||||
val majorVersion = siteData.version?.split(".")?.firstOrNull()?.toIntOrNull()
|
||||
if (majorVersion != null && majorVersion < 5) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Ghost ${siteData.version} is older than v5. Some features may not work.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Open Ghost Admin button
|
||||
val ghostAdminUrl = remember(activeAccount) {
|
||||
activeAccount?.blogUrl?.trimEnd('/') + "/ghost/"
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(ghostAdminUrl))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Open Ghost Admin")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
// --- Features section ---
|
||||
Text("Features", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
TagsSettingsSection(onNavigateToTags = onNavigateToTags)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// --- Current Account section ---
|
||||
Text("Current Account", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
|
@ -102,10 +286,7 @@ fun SettingsScreen(
|
|||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = activeAccount.blogUrl
|
||||
.removePrefix("https://")
|
||||
.removePrefix("http://")
|
||||
.removeSuffix("/"),
|
||||
text = activeAccount.blogUrl.toDisplayUrl(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
|
@ -128,6 +309,30 @@ fun SettingsScreen(
|
|||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Static Pages
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onNavigateToPages)
|
||||
.padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Static Pages",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = "\u203A",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Disconnect current account
|
||||
OutlinedButton(
|
||||
onClick = { showDisconnectDialog = true },
|
||||
|
|
@ -202,6 +407,69 @@ fun SettingsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TagsSettingsSection(onNavigateToTags: () -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
val tagsPreferences = remember { TagsPreferences(context) }
|
||||
var tagsEnabled by remember { mutableStateOf(tagsPreferences.isTagsEnabled()) }
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Enable tags",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = tagsEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
tagsEnabled = enabled
|
||||
tagsPreferences.setTagsEnabled(enabled)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Show tag management and tag filters in feed",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// Manage tags navigation
|
||||
AnimatedVisibility(
|
||||
visible = tagsEnabled,
|
||||
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
|
||||
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onNavigateToTags,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Tag,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Manage Tags")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemeModeSelector(
|
||||
currentMode: ThemeMode,
|
||||
|
|
|
|||
|
|
@ -9,24 +9,30 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
|||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
|
@ -34,6 +40,7 @@ import androidx.compose.ui.text.withStyle
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -102,6 +109,124 @@ fun SetupScreen(
|
|||
)
|
||||
|
||||
// Content layered on top
|
||||
if (state.showConfirmation) {
|
||||
// Confirmation card
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.9f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Site icon
|
||||
if (state.siteIcon != null) {
|
||||
AsyncImage(
|
||||
model = state.siteIcon,
|
||||
contentDescription = "Site icon",
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Site title
|
||||
Text(
|
||||
text = state.siteName ?: "Ghost Blog",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
// Site description
|
||||
if (!state.siteDescription.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = state.siteDescription!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Ghost version
|
||||
if (state.siteVersion != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Ghost ${state.siteVersion}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Version warning
|
||||
if (state.versionWarning) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Ghost version ${state.siteVersion} is older than v5. Some features may not work correctly.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Buttons
|
||||
Button(
|
||||
onClick = viewModel::confirmConnection,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Tak, po\u0142\u0105cz")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancelConfirmation,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Wstecz")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
|
|
@ -252,6 +377,7 @@ fun SetupScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.app.Application
|
|||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.swoosh.microblog.data.AccountManager
|
||||
import com.swoosh.microblog.data.SiteMetadataCache
|
||||
import com.swoosh.microblog.data.api.ApiClient
|
||||
import com.swoosh.microblog.data.api.GhostJwtGenerator
|
||||
import com.swoosh.microblog.data.repository.PostRepository
|
||||
|
|
@ -16,6 +17,7 @@ import kotlinx.coroutines.launch
|
|||
class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val accountManager = AccountManager(application)
|
||||
private val siteMetadataCache = SiteMetadataCache(application)
|
||||
|
||||
private val _uiState = MutableStateFlow(SetupUiState(
|
||||
isAddingAccount = accountManager.hasAnyAccount
|
||||
|
|
@ -34,6 +36,30 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
|||
_uiState.update { it.copy(accountName = name) }
|
||||
}
|
||||
|
||||
fun confirmConnection() {
|
||||
_uiState.update { it.copy(isSuccess = true) }
|
||||
}
|
||||
|
||||
fun cancelConfirmation() {
|
||||
// Remove the account that was added during save()
|
||||
val accountId = _uiState.value.pendingAccountId
|
||||
if (accountId != null) {
|
||||
accountManager.removeAccount(accountId)
|
||||
siteMetadataCache.remove(accountId)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
showConfirmation = false,
|
||||
siteName = null,
|
||||
siteDescription = null,
|
||||
siteIcon = null,
|
||||
siteVersion = null,
|
||||
versionWarning = false,
|
||||
pendingAccountId = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val state = _uiState.value
|
||||
if (state.url.isBlank() || state.apiKey.isBlank()) {
|
||||
|
|
@ -83,7 +109,39 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
|||
accountManager.updateAccount(id = account.id, avatarUrl = avatarUrl)
|
||||
}
|
||||
} catch (_: Exception) { /* avatar is best-effort */ }
|
||||
_uiState.update { it.copy(isTesting = false, isSuccess = true) }
|
||||
|
||||
// Fetch site metadata
|
||||
var siteLoaded = false
|
||||
try {
|
||||
val service = ApiClient.getService(
|
||||
state.url,
|
||||
apiKeyProvider = { state.apiKey }
|
||||
)
|
||||
val siteResponse = service.getSite()
|
||||
val site = siteResponse.body()
|
||||
if (site != null) {
|
||||
siteMetadataCache.save(account.id, site)
|
||||
val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isTesting = false,
|
||||
showConfirmation = true,
|
||||
siteName = site.title,
|
||||
siteDescription = site.description,
|
||||
siteIcon = site.icon ?: site.logo,
|
||||
siteVersion = site.version,
|
||||
versionWarning = majorVersion != null && majorVersion < 5,
|
||||
pendingAccountId = account.id
|
||||
)
|
||||
}
|
||||
siteLoaded = true
|
||||
}
|
||||
} catch (_: Exception) { /* site metadata is best-effort */ }
|
||||
|
||||
// Fallback: skip confirmation if site fetch failed
|
||||
if (!siteLoaded) {
|
||||
_uiState.update { it.copy(isTesting = false, isSuccess = true) }
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
// Remove the account since connection failed
|
||||
|
|
@ -110,5 +168,12 @@ data class SetupUiState(
|
|||
val isTesting: Boolean = false,
|
||||
val isSuccess: Boolean = false,
|
||||
val isAddingAccount: Boolean = false,
|
||||
val error: String? = null
|
||||
val error: String? = null,
|
||||
val siteName: String? = null,
|
||||
val siteDescription: String? = null,
|
||||
val siteIcon: String? = null,
|
||||
val siteVersion: String? = null,
|
||||
val showConfirmation: Boolean = false,
|
||||
val versionWarning: Boolean = false,
|
||||
val pendingAccountId: String? = null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,33 +1,40 @@
|
|||
package com.swoosh.microblog.ui.stats
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.animateIntAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Article
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material.icons.filled.*
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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 com.swoosh.microblog.data.model.GhostTagFull
|
||||
import com.swoosh.microblog.ui.tags.parseHexColor
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StatsScreen(
|
||||
viewModel: StatsViewModel = viewModel()
|
||||
viewModel: StatsViewModel = viewModel(),
|
||||
onNavigateToMembers: (() -> Unit)? = null
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// Animated counters — numbers count up from 0 when data loads
|
||||
// Animated counters -- numbers count up from 0 when data loads
|
||||
val animatedTotal by animateIntAsState(
|
||||
targetValue = state.stats.totalPosts,
|
||||
animationSpec = tween(400),
|
||||
|
|
@ -49,6 +56,39 @@ fun StatsScreen(
|
|||
label = "scheduled"
|
||||
)
|
||||
|
||||
// Member stat animations
|
||||
val memberStats = state.memberStats
|
||||
val animatedMembersTotal by animateIntAsState(
|
||||
targetValue = memberStats?.total ?: 0,
|
||||
animationSpec = tween(400),
|
||||
label = "membersTotal"
|
||||
)
|
||||
val animatedMembersNew by animateIntAsState(
|
||||
targetValue = memberStats?.newThisWeek ?: 0,
|
||||
animationSpec = tween(400),
|
||||
label = "membersNew"
|
||||
)
|
||||
val animatedMembersFree by animateIntAsState(
|
||||
targetValue = memberStats?.free ?: 0,
|
||||
animationSpec = tween(400),
|
||||
label = "membersFree"
|
||||
)
|
||||
val animatedMembersPaid by animateIntAsState(
|
||||
targetValue = memberStats?.paid ?: 0,
|
||||
animationSpec = tween(400),
|
||||
label = "membersPaid"
|
||||
)
|
||||
val animatedMembersMrr by animateIntAsState(
|
||||
targetValue = memberStats?.mrr ?: 0,
|
||||
animationSpec = tween(400),
|
||||
label = "membersMrr"
|
||||
)
|
||||
val animatedOpenRate by animateFloatAsState(
|
||||
targetValue = memberStats?.avgOpenRate?.toFloat() ?: 0f,
|
||||
animationSpec = tween(400),
|
||||
label = "openRate"
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
|
|
@ -114,6 +154,57 @@ fun StatsScreen(
|
|||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Tags section
|
||||
if (state.tagStats.isNotEmpty()) {
|
||||
Text(
|
||||
"Tags",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Most used tag
|
||||
if (state.mostUsedTag != null) {
|
||||
WritingStatRow(
|
||||
"Most used tag",
|
||||
"#${state.mostUsedTag!!.name} (${state.mostUsedTag!!.count?.posts ?: 0})"
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// Posts without tags
|
||||
WritingStatRow(
|
||||
"Posts without tags",
|
||||
"${state.postsWithoutTags}"
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Total tags
|
||||
WritingStatRow(
|
||||
"Total tags",
|
||||
"${state.tagStats.size}"
|
||||
)
|
||||
|
||||
// Tag progress bars
|
||||
val maxCount = state.tagStats.maxOfOrNull { it.count?.posts ?: 0 } ?: 1
|
||||
|
||||
state.tagStats.take(10).forEach { tag ->
|
||||
TagProgressBar(
|
||||
tag = tag,
|
||||
maxCount = maxCount.coerceAtLeast(1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Writing stats section
|
||||
Text(
|
||||
"Writing Stats",
|
||||
|
|
@ -140,6 +231,80 @@ fun StatsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Members section
|
||||
if (memberStats != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
"Members",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
MemberStatsCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = "$animatedMembersTotal",
|
||||
label = "Total",
|
||||
icon = Icons.Default.People
|
||||
)
|
||||
MemberStatsCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = "+$animatedMembersNew",
|
||||
label = "New this week",
|
||||
icon = Icons.Default.PersonAdd
|
||||
)
|
||||
MemberStatsCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = "${String.format("%.1f", animatedOpenRate)}%",
|
||||
label = "Open rate",
|
||||
icon = Icons.Default.MailOutline
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
MemberStatsCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = "$animatedMembersFree",
|
||||
label = "Free",
|
||||
icon = Icons.Default.Person
|
||||
)
|
||||
MemberStatsCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = "$animatedMembersPaid",
|
||||
label = "Paid",
|
||||
icon = Icons.Default.Diamond
|
||||
)
|
||||
MemberStatsCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = formatMrr(animatedMembersMrr),
|
||||
label = "MRR",
|
||||
icon = Icons.Default.AttachMoney
|
||||
)
|
||||
}
|
||||
|
||||
if (onNavigateToMembers != null) {
|
||||
TextButton(
|
||||
onClick = onNavigateToMembers,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("See all members")
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
|
|
@ -152,6 +317,77 @@ fun StatsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
private fun formatMrr(cents: Int): String {
|
||||
val dollars = cents / 100.0
|
||||
return if (dollars >= 1000) {
|
||||
String.format("$%.1fk", dollars / 1000)
|
||||
} else {
|
||||
String.format("$%.0f", dollars)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TagProgressBar(
|
||||
tag: GhostTagFull,
|
||||
maxCount: Int
|
||||
) {
|
||||
val postCount = tag.count?.posts ?: 0
|
||||
val progress = postCount.toFloat() / maxCount.toFloat()
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = progress,
|
||||
animationSpec = tween(600),
|
||||
label = "tagProgress_${tag.name}"
|
||||
)
|
||||
|
||||
val barColor = tag.accent_color?.let { parseHexColor(it) }
|
||||
?: MaterialTheme.colorScheme.primary
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(barColor)
|
||||
)
|
||||
Text(
|
||||
text = tag.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "$postCount",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp)),
|
||||
color = barColor,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsCard(
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -188,6 +424,42 @@ private fun StatsCard(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberStatsCard(
|
||||
modifier: Modifier = Modifier,
|
||||
value: String,
|
||||
label: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector
|
||||
) {
|
||||
ElevatedCard(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WritingStatRow(label: String, value: String) {
|
||||
Row(
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ import android.app.Application
|
|||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.swoosh.microblog.data.model.FeedPost
|
||||
import com.swoosh.microblog.data.model.GhostTagFull
|
||||
import com.swoosh.microblog.data.model.OverallStats
|
||||
import com.swoosh.microblog.data.repository.MemberRepository
|
||||
import com.swoosh.microblog.data.repository.MemberStats
|
||||
import com.swoosh.microblog.data.repository.PostRepository
|
||||
import com.swoosh.microblog.data.repository.TagRepository
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -15,6 +20,8 @@ import kotlinx.coroutines.launch
|
|||
class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val repository = PostRepository(application)
|
||||
private val memberRepository = MemberRepository(application)
|
||||
private val tagRepository = TagRepository(application)
|
||||
|
||||
private val _uiState = MutableStateFlow(StatsUiState())
|
||||
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
|
||||
|
|
@ -28,52 +35,94 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
|||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
try {
|
||||
// Get local posts
|
||||
val localPosts = repository.getAllLocalPostsList()
|
||||
|
||||
// Get remote posts
|
||||
val remotePosts = mutableListOf<FeedPost>()
|
||||
var page = 1
|
||||
var hasMore = true
|
||||
while (hasMore) {
|
||||
val result = repository.fetchPosts(page = page, limit = 50)
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
remotePosts.addAll(response.posts.map { ghost ->
|
||||
FeedPost(
|
||||
ghostId = ghost.id,
|
||||
title = ghost.title ?: "",
|
||||
textContent = ghost.plaintext ?: ghost.html?.replace(Regex("<[^>]*>"), "") ?: "",
|
||||
htmlContent = ghost.html,
|
||||
imageUrl = ghost.feature_image,
|
||||
linkUrl = null,
|
||||
linkTitle = null,
|
||||
linkDescription = null,
|
||||
linkImageUrl = null,
|
||||
status = ghost.status ?: "draft",
|
||||
publishedAt = ghost.published_at,
|
||||
createdAt = ghost.created_at,
|
||||
updatedAt = ghost.updated_at,
|
||||
isLocal = false
|
||||
)
|
||||
})
|
||||
hasMore = response.meta?.pagination?.next != null
|
||||
page++
|
||||
},
|
||||
onFailure = {
|
||||
hasMore = false
|
||||
}
|
||||
)
|
||||
// Safety limit
|
||||
if (page > 20) break
|
||||
// Launch posts, members, and tags fetches in parallel
|
||||
val postsDeferred = async {
|
||||
val localPosts = repository.getAllLocalPostsList()
|
||||
val remotePosts = mutableListOf<FeedPost>()
|
||||
var page = 1
|
||||
var hasMore = true
|
||||
while (hasMore) {
|
||||
val result = repository.fetchPosts(page = page, limit = 50)
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
remotePosts.addAll(response.posts.map { ghost ->
|
||||
FeedPost(
|
||||
ghostId = ghost.id,
|
||||
title = ghost.title ?: "",
|
||||
textContent = ghost.plaintext ?: ghost.html?.replace(Regex("<[^>]*>"), "") ?: "",
|
||||
htmlContent = ghost.html,
|
||||
imageUrl = ghost.feature_image,
|
||||
linkUrl = null,
|
||||
linkTitle = null,
|
||||
linkDescription = null,
|
||||
linkImageUrl = null,
|
||||
tags = ghost.tags?.map { it.name } ?: emptyList(),
|
||||
status = ghost.status ?: "draft",
|
||||
publishedAt = ghost.published_at,
|
||||
createdAt = ghost.created_at,
|
||||
updatedAt = ghost.updated_at,
|
||||
isLocal = false
|
||||
)
|
||||
})
|
||||
hasMore = response.meta?.pagination?.next != null
|
||||
page++
|
||||
},
|
||||
onFailure = {
|
||||
hasMore = false
|
||||
}
|
||||
)
|
||||
if (page > 20) break
|
||||
}
|
||||
val localGhostIds = localPosts.mapNotNull { it.ghostId }.toSet()
|
||||
val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds }
|
||||
Pair(localPosts, uniqueRemotePosts)
|
||||
}
|
||||
|
||||
// Remove remote duplicates that exist locally
|
||||
val localGhostIds = localPosts.mapNotNull { it.ghostId }.toSet()
|
||||
val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds }
|
||||
val membersDeferred = async {
|
||||
try {
|
||||
val membersResult = memberRepository.fetchAllMembers()
|
||||
membersResult.getOrNull()?.let { members ->
|
||||
memberRepository.getMemberStats(members)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val tagsDeferred = async {
|
||||
try {
|
||||
tagRepository.fetchTags().getOrNull()
|
||||
?.sortedByDescending { it.count?.posts ?: 0 }
|
||||
?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val (localPosts, uniqueRemotePosts) = postsDeferred.await()
|
||||
val memberStats = membersDeferred.await()
|
||||
val tagStats = tagsDeferred.await()
|
||||
|
||||
val stats = OverallStats.calculate(localPosts, uniqueRemotePosts)
|
||||
_uiState.update { it.copy(stats = stats, isLoading = false) }
|
||||
|
||||
// Count posts without any tags
|
||||
val totalPosts = localPosts.size + uniqueRemotePosts.size
|
||||
val postsWithTags = uniqueRemotePosts.count { it.tags.isNotEmpty() }
|
||||
val postsWithoutTags = totalPosts - postsWithTags
|
||||
|
||||
// Most used tag
|
||||
val mostUsedTag = tagStats.firstOrNull()
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
stats = stats,
|
||||
memberStats = memberStats,
|
||||
tagStats = tagStats,
|
||||
mostUsedTag = mostUsedTag,
|
||||
postsWithoutTags = postsWithoutTags,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
|
|
@ -83,6 +132,10 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
|||
|
||||
data class StatsUiState(
|
||||
val stats: OverallStats = OverallStats(),
|
||||
val memberStats: MemberStats? = null,
|
||||
val tagStats: List<GhostTagFull> = emptyList(),
|
||||
val mostUsedTag: GhostTagFull? = null,
|
||||
val postsWithoutTags: Int = 0,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
|
|
|||
448
app/src/main/java/com/swoosh/microblog/ui/tags/TagsScreen.kt
Normal file
448
app/src/main/java/com/swoosh/microblog/ui/tags/TagsScreen.kt
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
package com.swoosh.microblog.ui.tags
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.*
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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 com.swoosh.microblog.data.model.GhostTagFull
|
||||
import com.swoosh.microblog.ui.animation.SwooshMotion
|
||||
import com.swoosh.microblog.ui.components.ConfirmationDialog
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TagsScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: TagsViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// If editing a tag, show the edit screen
|
||||
if (state.editingTag != null) {
|
||||
TagEditScreen(
|
||||
tag = state.editingTag!!,
|
||||
isLoading = state.isLoading,
|
||||
error = state.error,
|
||||
onSave = viewModel::saveTag,
|
||||
onDelete = { id -> viewModel.deleteTag(id) },
|
||||
onBack = viewModel::cancelEditing
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Tags") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.startCreating() }) {
|
||||
Icon(Icons.Default.Add, "Create tag")
|
||||
}
|
||||
IconButton(onClick = { viewModel.loadTags() }) {
|
||||
Icon(Icons.Default.Refresh, "Refresh")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// Search field
|
||||
OutlinedTextField(
|
||||
value = state.searchQuery,
|
||||
onValueChange = viewModel::updateSearchQuery,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text("Search tags...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
},
|
||||
trailingIcon = {
|
||||
if (state.searchQuery.isNotBlank()) {
|
||||
IconButton(onClick = { viewModel.updateSearchQuery("") }) {
|
||||
Icon(Icons.Default.Close, "Clear search", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Loading indicator
|
||||
if (state.isLoading) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
// Error message
|
||||
if (state.error != null) {
|
||||
Text(
|
||||
text = state.error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Tags list
|
||||
if (state.filteredTags.isEmpty() && !state.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.Tag,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = if (state.searchQuery.isNotBlank()) "No tags match \"${state.searchQuery}\""
|
||||
else "No tags yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (state.searchQuery.isBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Tap + to create your first tag",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(
|
||||
state.filteredTags,
|
||||
key = { it.id ?: it.name }
|
||||
) { tag ->
|
||||
TagCard(
|
||||
tag = tag,
|
||||
onClick = { viewModel.startEditing(tag) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TagCard(
|
||||
tag: GhostTagFull,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
OutlinedCard(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Accent color dot
|
||||
val accentColor = tag.accent_color?.let { parseHexColor(it) }
|
||||
?: MaterialTheme.colorScheme.primary
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(accentColor)
|
||||
)
|
||||
|
||||
// Tag info
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = tag.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (tag.count?.posts != null) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer
|
||||
) {
|
||||
Text(
|
||||
text = "${tag.count.posts} posts",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!tag.description.isNullOrBlank()) {
|
||||
Text(
|
||||
text = tag.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = "Edit",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TagEditScreen(
|
||||
tag: GhostTagFull,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
onSave: (GhostTagFull) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val isNew = tag.id == null
|
||||
var name by remember(tag) { mutableStateOf(tag.name) }
|
||||
var description by remember(tag) { mutableStateOf(tag.description ?: "") }
|
||||
var accentColor by remember(tag) { mutableStateOf(tag.accent_color ?: "") }
|
||||
var visibility by remember(tag) { mutableStateOf(tag.visibility ?: "public") }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(if (isNew) "Create Tag" else "Edit Tag") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.size(48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSave(
|
||||
tag.copy(
|
||||
name = name,
|
||||
description = description.ifBlank { null },
|
||||
accent_color = accentColor.ifBlank { null },
|
||||
visibility = visibility
|
||||
)
|
||||
)
|
||||
},
|
||||
enabled = name.isNotBlank()
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Name
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
isError = name.isBlank()
|
||||
)
|
||||
|
||||
// Slug (read-only, only for existing tags)
|
||||
if (!isNew && tag.slug != null) {
|
||||
OutlinedTextField(
|
||||
value = tag.slug,
|
||||
onValueChange = {},
|
||||
label = { Text("Slug") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
readOnly = true,
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
|
||||
// Description
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
maxLines = 4
|
||||
)
|
||||
|
||||
// Accent color
|
||||
OutlinedTextField(
|
||||
value = accentColor,
|
||||
onValueChange = { accentColor = it },
|
||||
label = { Text("Accent Color (hex)") },
|
||||
placeholder = { Text("#FF5722") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
if (accentColor.isNotBlank()) {
|
||||
val color = parseHexColor(accentColor)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Visibility radio buttons
|
||||
Text(
|
||||
text = "Visibility",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { visibility = "public" }
|
||||
) {
|
||||
RadioButton(
|
||||
selected = visibility == "public",
|
||||
onClick = { visibility = "public" }
|
||||
)
|
||||
Text("Public", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { visibility = "internal" }
|
||||
) {
|
||||
RadioButton(
|
||||
selected = visibility == "internal",
|
||||
onClick = { visibility = "internal" }
|
||||
)
|
||||
Text("Internal", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
// Error
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
// Delete button (only for existing tags)
|
||||
if (!isNew) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedButton(
|
||||
onClick = { showDeleteDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Delete Tag")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteDialog && tag.id != null) {
|
||||
ConfirmationDialog(
|
||||
title = "Delete Tag?",
|
||||
message = "Delete \"${tag.name}\"? This will remove the tag from all posts.",
|
||||
confirmLabel = "Delete",
|
||||
onConfirm = {
|
||||
showDeleteDialog = false
|
||||
onDelete(tag.id)
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a hex color string (e.g., "#FF5722" or "FF5722") into a Color.
|
||||
* Returns a default color if parsing fails.
|
||||
*/
|
||||
fun parseHexColor(hex: String): Color {
|
||||
return try {
|
||||
val cleanHex = hex.removePrefix("#")
|
||||
if (cleanHex.length == 6) {
|
||||
Color(android.graphics.Color.parseColor("#$cleanHex"))
|
||||
} else {
|
||||
Color(0xFF888888)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Color(0xFF888888)
|
||||
}
|
||||
}
|
||||
120
app/src/main/java/com/swoosh/microblog/ui/tags/TagsViewModel.kt
Normal file
120
app/src/main/java/com/swoosh/microblog/ui/tags/TagsViewModel.kt
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package com.swoosh.microblog.ui.tags
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.swoosh.microblog.data.model.GhostTagFull
|
||||
import com.swoosh.microblog.data.repository.TagRepository
|
||||
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 TagsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val tagRepository = TagRepository(application)
|
||||
|
||||
private val _uiState = MutableStateFlow(TagsUiState())
|
||||
val uiState: StateFlow<TagsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadTags()
|
||||
}
|
||||
|
||||
fun loadTags() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
tagRepository.fetchTags().fold(
|
||||
onSuccess = { tags ->
|
||||
_uiState.update { it.copy(tags = tags, isLoading = false) }
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSearchQuery(query: String) {
|
||||
_uiState.update { it.copy(searchQuery = query) }
|
||||
}
|
||||
|
||||
fun startEditing(tag: GhostTagFull) {
|
||||
_uiState.update { it.copy(editingTag = tag) }
|
||||
}
|
||||
|
||||
fun startCreating() {
|
||||
_uiState.update {
|
||||
it.copy(editingTag = GhostTagFull(name = ""))
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelEditing() {
|
||||
_uiState.update { it.copy(editingTag = null) }
|
||||
}
|
||||
|
||||
fun saveTag(tag: GhostTagFull) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
if (tag.id != null) {
|
||||
// Update existing tag
|
||||
tagRepository.updateTag(tag.id, tag).fold(
|
||||
onSuccess = {
|
||||
_uiState.update { it.copy(editingTag = null) }
|
||||
loadTags()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Create new tag
|
||||
tagRepository.createTag(
|
||||
name = tag.name,
|
||||
description = tag.description,
|
||||
accentColor = tag.accent_color
|
||||
).fold(
|
||||
onSuccess = {
|
||||
_uiState.update { it.copy(editingTag = null) }
|
||||
loadTags()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteTag(id: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
tagRepository.deleteTag(id).fold(
|
||||
onSuccess = {
|
||||
_uiState.update { it.copy(editingTag = null) }
|
||||
loadTags()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
}
|
||||
|
||||
data class TagsUiState(
|
||||
val tags: List<GhostTagFull> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val searchQuery: String = "",
|
||||
val editingTag: GhostTagFull? = null
|
||||
) {
|
||||
val filteredTags: List<GhostTagFull>
|
||||
get() = if (searchQuery.isBlank()) tags
|
||||
else tags.filter { it.name.contains(searchQuery, ignoreCase = true) }
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ class PostUploadWorker(
|
|||
workerParams: WorkerParameters
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val repository = PostRepository(applicationContext)
|
||||
val queuedPosts = repository.getQueuedPosts()
|
||||
|
|
@ -66,25 +68,73 @@ class PostUploadWorker(
|
|||
featureImage = allImageUrls.first()
|
||||
}
|
||||
|
||||
// Upload video if needed
|
||||
var videoUrl = post.uploadedVideoUrl
|
||||
if (videoUrl == null && post.videoUri != null) {
|
||||
val videoResult = repository.uploadMediaFile(Uri.parse(post.videoUri))
|
||||
if (videoResult.isFailure) {
|
||||
repository.updateQueueStatus(post.localId, QueueStatus.FAILED)
|
||||
allSuccess = false
|
||||
continue
|
||||
}
|
||||
videoUrl = videoResult.getOrNull()
|
||||
}
|
||||
|
||||
// Upload audio if needed
|
||||
var audioUrl = post.uploadedAudioUrl
|
||||
if (audioUrl == null && post.audioUri != null) {
|
||||
val audioResult = repository.uploadMediaFile(Uri.parse(post.audioUri))
|
||||
if (audioResult.isFailure) {
|
||||
repository.updateQueueStatus(post.localId, QueueStatus.FAILED)
|
||||
allSuccess = false
|
||||
continue
|
||||
}
|
||||
audioUrl = audioResult.getOrNull()
|
||||
}
|
||||
|
||||
// Upload file if needed
|
||||
var uploadedFileUrl = post.uploadedFileUrl
|
||||
if (uploadedFileUrl == null && post.fileUri != null) {
|
||||
val fileResult = repository.uploadFile(Uri.parse(post.fileUri))
|
||||
if (fileResult.isFailure) {
|
||||
repository.updateQueueStatus(post.localId, QueueStatus.FAILED)
|
||||
allSuccess = false
|
||||
continue
|
||||
}
|
||||
uploadedFileUrl = fileResult.getOrNull()
|
||||
}
|
||||
|
||||
val mobiledoc = MobiledocBuilder.build(
|
||||
post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription,
|
||||
post.imageAlt
|
||||
text = post.content,
|
||||
imageUrls = allImageUrls,
|
||||
linkUrl = post.linkUrl,
|
||||
linkTitle = post.linkTitle,
|
||||
linkDescription = post.linkDescription,
|
||||
imageAlt = post.imageAlt,
|
||||
videoUrl = videoUrl,
|
||||
audioUrl = audioUrl,
|
||||
fileUrl = uploadedFileUrl,
|
||||
fileName = post.fileName,
|
||||
fileSize = 0 // Size not stored in LocalPost; Ghost only needs src/fileName
|
||||
)
|
||||
|
||||
// Parse tags from JSON stored in LocalPost
|
||||
val tagNames: List<String> = try {
|
||||
Gson().fromJson(post.tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
|
||||
gson.fromJson(post.tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
val ghostTags = tagNames.map { GhostTag(name = it) }
|
||||
|
||||
val isEmailOnly = post.queueStatus == QueueStatus.QUEUED_EMAIL_ONLY
|
||||
|
||||
val ghostPost = GhostPost(
|
||||
title = post.title,
|
||||
mobiledoc = mobiledoc,
|
||||
status = when (post.queueStatus) {
|
||||
QueueStatus.QUEUED_PUBLISH -> "published"
|
||||
QueueStatus.QUEUED_SCHEDULED -> "scheduled"
|
||||
QueueStatus.QUEUED_EMAIL_ONLY -> "published"
|
||||
else -> "draft"
|
||||
},
|
||||
featured = post.featured,
|
||||
|
|
@ -92,13 +142,17 @@ class PostUploadWorker(
|
|||
feature_image_alt = post.imageAlt,
|
||||
published_at = post.scheduledAt,
|
||||
visibility = "public",
|
||||
tags = ghostTags.ifEmpty { null }
|
||||
tags = ghostTags.ifEmpty { null },
|
||||
email_only = if (isEmailOnly) true else null
|
||||
)
|
||||
|
||||
// Determine newsletter slug for email-only or newsletter posts
|
||||
val newsletterSlug = post.newsletterSlug
|
||||
|
||||
val result = if (post.ghostId != null) {
|
||||
repository.updatePost(post.ghostId, ghostPost)
|
||||
repository.updatePost(post.ghostId, ghostPost, newsletter = newsletterSlug)
|
||||
} else {
|
||||
repository.createPost(ghostPost)
|
||||
repository.createPost(ghostPost, newsletter = newsletterSlug)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with separate params and no link produces same as null preview`() {
|
||||
val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?)
|
||||
val resultB = MobiledocBuilder.build("Hello", null, null, null)
|
||||
val resultB = MobiledocBuilder.build("Hello")
|
||||
assertEquals(resultA, resultB)
|
||||
}
|
||||
|
||||
|
|
@ -207,9 +207,9 @@ class MobiledocBuilderTest {
|
|||
fun `build with separate params includes link data`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text",
|
||||
"https://test.com",
|
||||
"Test Title",
|
||||
"Test Desc"
|
||||
linkUrl = "https://test.com",
|
||||
linkTitle = "Test Title",
|
||||
linkDescription = "Test Desc"
|
||||
)
|
||||
assertTrue(result.contains("https://test.com"))
|
||||
assertTrue(result.contains("Test Title"))
|
||||
|
|
@ -219,7 +219,7 @@ class MobiledocBuilderTest {
|
|||
|
||||
@Test
|
||||
fun `build with separate params handles null title and description`() {
|
||||
val result = MobiledocBuilder.build("Text", "https://test.com", null, null)
|
||||
val result = MobiledocBuilder.build("Text", linkUrl = "https://test.com")
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull(json)
|
||||
assertTrue(result.contains("bookmark"))
|
||||
|
|
@ -257,8 +257,9 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with image card produces valid JSON`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Post text", null, null, null,
|
||||
"https://example.com/photo.jpg", "A sunset"
|
||||
"Post text",
|
||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
||||
imageAlt = "A sunset"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull(json)
|
||||
|
|
@ -267,8 +268,9 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with image card includes image type`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", null, null, null,
|
||||
"https://example.com/photo.jpg", "Alt text"
|
||||
"Text",
|
||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
||||
imageAlt = "Alt text"
|
||||
)
|
||||
assertTrue("Should contain image card type", result.contains("\"image\""))
|
||||
}
|
||||
|
|
@ -276,8 +278,9 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with image card includes src`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", null, null, null,
|
||||
"https://example.com/photo.jpg", "Alt text"
|
||||
"Text",
|
||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
||||
imageAlt = "Alt text"
|
||||
)
|
||||
assertTrue("Should contain image src", result.contains("https://example.com/photo.jpg"))
|
||||
}
|
||||
|
|
@ -285,8 +288,9 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with image card includes alt text`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", null, null, null,
|
||||
"https://example.com/photo.jpg", "A beautiful sunset"
|
||||
"Text",
|
||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
||||
imageAlt = "A beautiful sunset"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
|
|
@ -300,8 +304,8 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with image card and null alt uses empty string`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", null, null, null,
|
||||
"https://example.com/photo.jpg", null
|
||||
"Text",
|
||||
imageUrls = listOf("https://example.com/photo.jpg")
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
|
|
@ -313,8 +317,9 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with image card includes caption field`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", null, null, null,
|
||||
"https://example.com/photo.jpg", "Alt"
|
||||
"Text",
|
||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
||||
imageAlt = "Alt"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val card = json.getAsJsonArray("cards").get(0).asJsonArray
|
||||
|
|
@ -325,8 +330,9 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with image card has card section`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", null, null, null,
|
||||
"https://example.com/photo.jpg", "Alt"
|
||||
"Text",
|
||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
||||
imageAlt = "Alt"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val sections = json.getAsJsonArray("sections")
|
||||
|
|
@ -336,8 +342,12 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with image and link has both cards`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", "https://link.com", "Link Title", "Link Desc",
|
||||
"https://example.com/photo.jpg", "Image alt"
|
||||
"Text",
|
||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
||||
linkUrl = "https://link.com",
|
||||
linkTitle = "Link Title",
|
||||
linkDescription = "Link Desc",
|
||||
imageAlt = "Image alt"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
|
|
@ -350,8 +360,12 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with image and link has three sections`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", "https://link.com", "Title", "Desc",
|
||||
"https://example.com/photo.jpg", "Alt"
|
||||
"Text",
|
||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
||||
linkUrl = "https://link.com",
|
||||
linkTitle = "Title",
|
||||
linkDescription = "Desc",
|
||||
imageAlt = "Alt"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val sections = json.getAsJsonArray("sections")
|
||||
|
|
@ -361,8 +375,9 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with image card escapes alt text`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", null, null, null,
|
||||
"https://example.com/photo.jpg", "He said \"hello\""
|
||||
"Text",
|
||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
||||
imageAlt = "He said \"hello\""
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull("Should produce valid JSON with escaped alt text", json)
|
||||
|
|
@ -370,10 +385,7 @@ class MobiledocBuilderTest {
|
|||
|
||||
@Test
|
||||
fun `build without image produces no image card`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", null, null, null,
|
||||
null, null
|
||||
)
|
||||
val result = MobiledocBuilder.build("Text")
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertTrue("Should have no cards", json.getAsJsonArray("cards").isEmpty)
|
||||
}
|
||||
|
|
@ -381,8 +393,9 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with image card section references correct card index`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", null, null, null,
|
||||
"https://example.com/photo.jpg", "Alt"
|
||||
"Text",
|
||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
||||
imageAlt = "Alt"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val sections = json.getAsJsonArray("sections")
|
||||
|
|
@ -394,8 +407,11 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with image and link card sections reference correct indices`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", "https://link.com", "Title", null,
|
||||
"https://example.com/photo.jpg", "Alt"
|
||||
"Text",
|
||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
||||
linkUrl = "https://link.com",
|
||||
linkTitle = "Title",
|
||||
imageAlt = "Alt"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val sections = json.getAsJsonArray("sections")
|
||||
|
|
@ -414,7 +430,7 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with single image produces valid JSON`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Hello", listOf("https://example.com/img.jpg"), null, null, null
|
||||
"Hello", listOf("https://example.com/img.jpg")
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull(json)
|
||||
|
|
@ -423,7 +439,7 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with single image has one image card`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Hello", listOf("https://example.com/img.jpg"), null, null, null
|
||||
"Hello", listOf("https://example.com/img.jpg")
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertEquals(1, json.getAsJsonArray("cards").size())
|
||||
|
|
@ -436,7 +452,7 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with single image has two sections`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Hello", listOf("https://example.com/img.jpg"), null, null, null
|
||||
"Hello", listOf("https://example.com/img.jpg")
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertEquals(2, json.getAsJsonArray("sections").size())
|
||||
|
|
@ -449,7 +465,7 @@ class MobiledocBuilderTest {
|
|||
"https://example.com/img2.jpg",
|
||||
"https://example.com/img3.jpg"
|
||||
)
|
||||
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||
val result = MobiledocBuilder.build("Hello", images)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
|
@ -461,7 +477,7 @@ class MobiledocBuilderTest {
|
|||
"https://example.com/img2.jpg",
|
||||
"https://example.com/img3.jpg"
|
||||
)
|
||||
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||
val result = MobiledocBuilder.build("Hello", images)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertEquals(3, json.getAsJsonArray("cards").size())
|
||||
}
|
||||
|
|
@ -472,7 +488,7 @@ class MobiledocBuilderTest {
|
|||
"https://example.com/img1.jpg",
|
||||
"https://example.com/img2.jpg"
|
||||
)
|
||||
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||
val result = MobiledocBuilder.build("Hello", images)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
// 1 text section + 2 card sections
|
||||
assertEquals(3, json.getAsJsonArray("sections").size())
|
||||
|
|
@ -484,7 +500,7 @@ class MobiledocBuilderTest {
|
|||
"https://example.com/img1.jpg",
|
||||
"https://example.com/img2.jpg"
|
||||
)
|
||||
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||
val result = MobiledocBuilder.build("Hello", images)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
for (i in 0 until cards.size()) {
|
||||
|
|
@ -500,7 +516,7 @@ class MobiledocBuilderTest {
|
|||
"https://example.com/img2.jpg",
|
||||
"https://example.com/img3.jpg"
|
||||
)
|
||||
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||
val result = MobiledocBuilder.build("Hello", images)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
assertEquals("https://example.com/img1.jpg", cards.get(0).asJsonArray.get(1).asJsonObject.get("src").asString)
|
||||
|
|
@ -512,7 +528,7 @@ class MobiledocBuilderTest {
|
|||
fun `build with images and link has both image and bookmark cards`() {
|
||||
val images = listOf("https://example.com/img1.jpg")
|
||||
val result = MobiledocBuilder.build(
|
||||
"Hello", images, "https://example.com", "Title", "Desc"
|
||||
"Hello", images, linkUrl = "https://example.com", linkTitle = "Title", linkDescription = "Desc"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
|
|
@ -528,7 +544,7 @@ class MobiledocBuilderTest {
|
|||
fun `build with images and link has correct number of sections`() {
|
||||
val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
|
||||
val result = MobiledocBuilder.build(
|
||||
"Hello", images, "https://example.com", "Title", "Desc"
|
||||
"Hello", images, linkUrl = "https://example.com", linkTitle = "Title", linkDescription = "Desc"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
// 1 text section + 2 image card sections + 1 bookmark card section
|
||||
|
|
@ -538,7 +554,7 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with images card sections reference correct card indices`() {
|
||||
val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
|
||||
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||
val result = MobiledocBuilder.build("Hello", images)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val sections = json.getAsJsonArray("sections")
|
||||
|
||||
|
|
@ -556,7 +572,7 @@ class MobiledocBuilderTest {
|
|||
|
||||
@Test
|
||||
fun `build with empty image list produces no image cards`() {
|
||||
val result = MobiledocBuilder.build("Hello", emptyList(), null, null, null)
|
||||
val result = MobiledocBuilder.build("Hello", emptyList())
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertTrue(json.getAsJsonArray("cards").isEmpty)
|
||||
assertEquals(1, json.getAsJsonArray("sections").size())
|
||||
|
|
@ -565,14 +581,14 @@ class MobiledocBuilderTest {
|
|||
@Test
|
||||
fun `build with empty image list matches no-image build`() {
|
||||
val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?)
|
||||
val resultB = MobiledocBuilder.build("Hello", emptyList(), null, null, null)
|
||||
val resultB = MobiledocBuilder.build("Hello", emptyList())
|
||||
assertEquals(resultA, resultB)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with image URL containing special chars produces valid JSON`() {
|
||||
val images = listOf("https://example.com/img?id=1&name=\"test\"")
|
||||
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||
val result = MobiledocBuilder.build("Hello", images)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
|
@ -585,7 +601,7 @@ class MobiledocBuilderTest {
|
|||
"https://example.com/img1.jpg",
|
||||
"https://example.com/img2.jpg"
|
||||
)
|
||||
val result = MobiledocBuilder.build("Text", images, null, null, null, "First image alt")
|
||||
val result = MobiledocBuilder.build("Text", images, imageAlt = "First image alt")
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
|
||||
|
|
@ -597,4 +613,249 @@ class MobiledocBuilderTest {
|
|||
val secondCard = cards.get(1).asJsonArray.get(1).asJsonObject
|
||||
assertEquals("", secondCard.get("alt").asString)
|
||||
}
|
||||
|
||||
// --- Video card tests ---
|
||||
|
||||
@Test
|
||||
fun `build with video only produces valid JSON with video card`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Check this video",
|
||||
videoUrl = "https://example.com/video.mp4"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull(json)
|
||||
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
assertEquals(1, cards.size())
|
||||
val card = cards.get(0).asJsonArray
|
||||
assertEquals("video", card.get(0).asString)
|
||||
val cardData = card.get(1).asJsonObject
|
||||
assertEquals("https://example.com/video.mp4", cardData.get("src").asString)
|
||||
assertFalse(cardData.get("loop").asBoolean)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with video has correct sections`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text",
|
||||
videoUrl = "https://example.com/video.mp4"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val sections = json.getAsJsonArray("sections")
|
||||
assertEquals("Should have text section + video card section", 2, sections.size())
|
||||
|
||||
val videoSection = sections.get(1).asJsonArray
|
||||
assertEquals(10, videoSection.get(0).asInt)
|
||||
assertEquals(0, videoSection.get(1).asInt)
|
||||
}
|
||||
|
||||
// --- Audio card tests ---
|
||||
|
||||
@Test
|
||||
fun `build with audio only produces valid JSON with audio card`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Listen to this",
|
||||
audioUrl = "https://example.com/audio.mp3"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull(json)
|
||||
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
assertEquals(1, cards.size())
|
||||
val card = cards.get(0).asJsonArray
|
||||
assertEquals("audio", card.get(0).asString)
|
||||
val cardData = card.get(1).asJsonObject
|
||||
assertEquals("https://example.com/audio.mp3", cardData.get("src").asString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with audio has correct sections`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text",
|
||||
audioUrl = "https://example.com/audio.mp3"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val sections = json.getAsJsonArray("sections")
|
||||
assertEquals("Should have text section + audio card section", 2, sections.size())
|
||||
}
|
||||
|
||||
// --- Combined: text + images + video + audio + bookmark ---
|
||||
|
||||
@Test
|
||||
fun `build with all media types produces valid JSON with correct card order`() {
|
||||
val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
|
||||
val result = MobiledocBuilder.build(
|
||||
"Full post", images,
|
||||
linkUrl = "https://link.com",
|
||||
linkTitle = "Link Title",
|
||||
linkDescription = "Link Desc",
|
||||
imageAlt = "Alt text",
|
||||
videoUrl = "https://example.com/video.mp4",
|
||||
audioUrl = "https://example.com/audio.mp3"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull(json)
|
||||
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
// 2 images + 1 video + 1 audio + 1 bookmark = 5 cards
|
||||
assertEquals(5, cards.size())
|
||||
|
||||
// Verify card order: image, image, video, audio, bookmark
|
||||
assertEquals("image", cards.get(0).asJsonArray.get(0).asString)
|
||||
assertEquals("image", cards.get(1).asJsonArray.get(0).asString)
|
||||
assertEquals("video", cards.get(2).asJsonArray.get(0).asString)
|
||||
assertEquals("audio", cards.get(3).asJsonArray.get(0).asString)
|
||||
assertEquals("bookmark", cards.get(4).asJsonArray.get(0).asString)
|
||||
|
||||
// Verify sections: 1 text + 5 card sections = 6
|
||||
val sections = json.getAsJsonArray("sections")
|
||||
assertEquals(6, sections.size())
|
||||
|
||||
// Verify card section indices are sequential
|
||||
for (i in 0 until 5) {
|
||||
val cardSection = sections.get(i + 1).asJsonArray
|
||||
assertEquals(10, cardSection.get(0).asInt)
|
||||
assertEquals(i, cardSection.get(1).asInt)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with video and audio but no images produces correct cards`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Media post",
|
||||
videoUrl = "https://example.com/video.mp4",
|
||||
audioUrl = "https://example.com/audio.mp3"
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
assertEquals(2, cards.size())
|
||||
assertEquals("video", cards.get(0).asJsonArray.get(0).asString)
|
||||
assertEquals("audio", cards.get(1).asJsonArray.get(0).asString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with video URL containing special chars produces valid JSON`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text",
|
||||
videoUrl = "https://example.com/video?id=1&name=\"test\""
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
||||
// --- File card tests ---
|
||||
|
||||
@Test
|
||||
fun `build with file card produces valid JSON`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Post text",
|
||||
fileUrl = "https://example.com/files/report.pdf",
|
||||
fileName = "report.pdf",
|
||||
fileSize = 102400
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with file card includes file type`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text",
|
||||
fileUrl = "https://example.com/files/doc.pdf",
|
||||
fileName = "doc.pdf",
|
||||
fileSize = 5000
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
assertEquals(1, cards.size())
|
||||
val card = cards.get(0).asJsonArray
|
||||
assertEquals("file", card.get(0).asString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with file card includes src fileName and fileSize`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text",
|
||||
fileUrl = "https://example.com/files/report.pdf",
|
||||
fileName = "report.pdf",
|
||||
fileSize = 204800
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
val cardData = cards.get(0).asJsonArray.get(1).asJsonObject
|
||||
assertEquals("https://example.com/files/report.pdf", cardData.get("src").asString)
|
||||
assertEquals("report.pdf", cardData.get("fileName").asString)
|
||||
assertEquals(204800, cardData.get("fileSize").asLong)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with file card has correct section count`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text",
|
||||
fileUrl = "https://example.com/file.pdf",
|
||||
fileName = "file.pdf",
|
||||
fileSize = 1000
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val sections = json.getAsJsonArray("sections")
|
||||
assertEquals("Should have text section and file card section", 2, sections.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with file card comes after image and bookmark cards`() {
|
||||
val images = listOf("https://example.com/img.jpg")
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", images,
|
||||
linkUrl = "https://link.com",
|
||||
linkTitle = "Link Title",
|
||||
linkDescription = "Desc",
|
||||
imageAlt = "Alt",
|
||||
fileUrl = "https://example.com/file.pdf",
|
||||
fileName = "file.pdf",
|
||||
fileSize = 1024
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
val cards = json.getAsJsonArray("cards")
|
||||
assertEquals(3, cards.size())
|
||||
// Image first, bookmark second, file last
|
||||
assertEquals("image", cards.get(0).asJsonArray.get(0).asString)
|
||||
assertEquals("bookmark", cards.get(1).asJsonArray.get(0).asString)
|
||||
assertEquals("file", cards.get(2).asJsonArray.get(0).asString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with file card and all attachments has correct section count`() {
|
||||
val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text", images,
|
||||
linkUrl = "https://link.com",
|
||||
linkTitle = "Title",
|
||||
linkDescription = "Desc",
|
||||
fileUrl = "https://example.com/file.pdf",
|
||||
fileName = "file.pdf",
|
||||
fileSize = 500
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
// 1 text + 2 image + 1 bookmark + 1 file = 5
|
||||
assertEquals(5, json.getAsJsonArray("sections").size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build without file produces no file card`() {
|
||||
val result = MobiledocBuilder.build("Text")
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertTrue(json.getAsJsonArray("cards").isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build with file card escapes fileName`() {
|
||||
val result = MobiledocBuilder.build(
|
||||
"Text",
|
||||
fileUrl = "https://example.com/file.pdf",
|
||||
fileName = "my \"special\" file.pdf",
|
||||
fileSize = 100
|
||||
)
|
||||
val json = JsonParser.parseString(result).asJsonObject
|
||||
assertNotNull("Should produce valid JSON with escaped file name", json)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
package com.swoosh.microblog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], application = android.app.Application::class)
|
||||
class NewsletterPreferencesTest {
|
||||
|
||||
private lateinit var prefs: SharedPreferences
|
||||
private lateinit var newsletterPreferences: NewsletterPreferences
|
||||
|
||||
private val testAccountId = "test-account-123"
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
prefs = context.getSharedPreferences(NewsletterPreferences.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().clear().commit()
|
||||
newsletterPreferences = NewsletterPreferences(prefs, testAccountId)
|
||||
}
|
||||
|
||||
// --- Default values ---
|
||||
|
||||
@Test
|
||||
fun `default newsletter enabled is false`() {
|
||||
assertFalse(newsletterPreferences.isNewsletterEnabled())
|
||||
}
|
||||
|
||||
// --- Setting and getting ---
|
||||
|
||||
@Test
|
||||
fun `setting newsletter enabled to true persists`() {
|
||||
newsletterPreferences.setNewsletterEnabled(true)
|
||||
assertTrue(newsletterPreferences.isNewsletterEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setting newsletter enabled to false persists`() {
|
||||
newsletterPreferences.setNewsletterEnabled(true)
|
||||
newsletterPreferences.setNewsletterEnabled(false)
|
||||
assertFalse(newsletterPreferences.isNewsletterEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newsletter enabled persists across instances`() {
|
||||
newsletterPreferences.setNewsletterEnabled(true)
|
||||
val newInstance = NewsletterPreferences(prefs, testAccountId)
|
||||
assertTrue(newInstance.isNewsletterEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggling on then off round-trips correctly`() {
|
||||
newsletterPreferences.setNewsletterEnabled(true)
|
||||
assertTrue(newsletterPreferences.isNewsletterEnabled())
|
||||
newsletterPreferences.setNewsletterEnabled(false)
|
||||
assertFalse(newsletterPreferences.isNewsletterEnabled())
|
||||
}
|
||||
|
||||
// --- Per-account isolation ---
|
||||
|
||||
@Test
|
||||
fun `different accounts have independent newsletter settings`() {
|
||||
val prefs1 = NewsletterPreferences(prefs, "account-1")
|
||||
val prefs2 = NewsletterPreferences(prefs, "account-2")
|
||||
|
||||
prefs1.setNewsletterEnabled(true)
|
||||
prefs2.setNewsletterEnabled(false)
|
||||
|
||||
assertTrue(prefs1.isNewsletterEnabled())
|
||||
assertFalse(prefs2.isNewsletterEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabling for one account does not affect another`() {
|
||||
val prefs1 = NewsletterPreferences(prefs, "account-a")
|
||||
val prefs2 = NewsletterPreferences(prefs, "account-b")
|
||||
|
||||
prefs1.setNewsletterEnabled(true)
|
||||
|
||||
assertTrue(prefs1.isNewsletterEnabled())
|
||||
assertFalse(prefs2.isNewsletterEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty account id still works`() {
|
||||
val emptyPrefs = NewsletterPreferences(prefs, "")
|
||||
emptyPrefs.setNewsletterEnabled(true)
|
||||
assertTrue(emptyPrefs.isNewsletterEnabled())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
package com.swoosh.microblog.data
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.swoosh.microblog.data.model.GhostSite
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class SiteMetadataCacheTest {
|
||||
|
||||
private lateinit var cache: SiteMetadataCache
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
cache = SiteMetadataCache(context)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save and get round trip`() {
|
||||
val site = GhostSite(
|
||||
title = "My Blog",
|
||||
description = "A test blog",
|
||||
logo = "https://example.com/logo.png",
|
||||
icon = "https://example.com/icon.png",
|
||||
accent_color = "#ff1a75",
|
||||
url = "https://example.com/",
|
||||
version = "5.82.0",
|
||||
locale = "en"
|
||||
)
|
||||
|
||||
cache.save("account-1", site)
|
||||
val retrieved = cache.get("account-1")
|
||||
|
||||
assertNotNull(retrieved)
|
||||
assertEquals("My Blog", retrieved!!.title)
|
||||
assertEquals("A test blog", retrieved.description)
|
||||
assertEquals("https://example.com/logo.png", retrieved.logo)
|
||||
assertEquals("https://example.com/icon.png", retrieved.icon)
|
||||
assertEquals("#ff1a75", retrieved.accent_color)
|
||||
assertEquals("https://example.com/", retrieved.url)
|
||||
assertEquals("5.82.0", retrieved.version)
|
||||
assertEquals("en", retrieved.locale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get returns null for unknown account`() {
|
||||
val result = cache.get("nonexistent-account")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getVersion returns version string`() {
|
||||
val site = GhostSite(
|
||||
title = "Blog",
|
||||
description = null,
|
||||
logo = null,
|
||||
icon = null,
|
||||
accent_color = null,
|
||||
url = null,
|
||||
version = "5.82.0",
|
||||
locale = null
|
||||
)
|
||||
|
||||
cache.save("account-2", site)
|
||||
assertEquals("5.82.0", cache.getVersion("account-2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getVersion returns null for unknown account`() {
|
||||
assertNull(cache.getVersion("nonexistent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save overwrites existing data`() {
|
||||
val site1 = GhostSite(
|
||||
title = "Old Title",
|
||||
description = null,
|
||||
logo = null,
|
||||
icon = null,
|
||||
accent_color = null,
|
||||
url = null,
|
||||
version = "5.0.0",
|
||||
locale = null
|
||||
)
|
||||
val site2 = GhostSite(
|
||||
title = "New Title",
|
||||
description = "Updated",
|
||||
logo = null,
|
||||
icon = null,
|
||||
accent_color = null,
|
||||
url = null,
|
||||
version = "5.82.0",
|
||||
locale = null
|
||||
)
|
||||
|
||||
cache.save("account-3", site1)
|
||||
cache.save("account-3", site2)
|
||||
val retrieved = cache.get("account-3")
|
||||
|
||||
assertEquals("New Title", retrieved?.title)
|
||||
assertEquals("Updated", retrieved?.description)
|
||||
assertEquals("5.82.0", retrieved?.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different accounts have independent data`() {
|
||||
val site1 = GhostSite(
|
||||
title = "Blog One",
|
||||
description = null,
|
||||
logo = null,
|
||||
icon = null,
|
||||
accent_color = null,
|
||||
url = null,
|
||||
version = "5.0.0",
|
||||
locale = null
|
||||
)
|
||||
val site2 = GhostSite(
|
||||
title = "Blog Two",
|
||||
description = null,
|
||||
logo = null,
|
||||
icon = null,
|
||||
accent_color = null,
|
||||
url = null,
|
||||
version = "4.48.0",
|
||||
locale = null
|
||||
)
|
||||
|
||||
cache.save("account-a", site1)
|
||||
cache.save("account-b", site2)
|
||||
|
||||
assertEquals("Blog One", cache.get("account-a")?.title)
|
||||
assertEquals("Blog Two", cache.get("account-b")?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove deletes cached data`() {
|
||||
val site = GhostSite(
|
||||
title = "To Remove",
|
||||
description = null,
|
||||
logo = null,
|
||||
icon = null,
|
||||
accent_color = null,
|
||||
url = null,
|
||||
version = "5.0.0",
|
||||
locale = null
|
||||
)
|
||||
|
||||
cache.save("account-remove", site)
|
||||
assertNotNull(cache.get("account-remove"))
|
||||
|
||||
cache.remove("account-remove")
|
||||
assertNull(cache.get("account-remove"))
|
||||
}
|
||||
}
|
||||
|
|
@ -56,9 +56,9 @@ class ConvertersTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `toPostStatus throws on invalid string`() {
|
||||
converters.toPostStatus("INVALID")
|
||||
@Test
|
||||
fun `toPostStatus returns DRAFT fallback on invalid string`() {
|
||||
assertEquals(PostStatus.DRAFT, converters.toPostStatus("INVALID"))
|
||||
}
|
||||
|
||||
// --- QueueStatus conversions ---
|
||||
|
|
@ -112,9 +112,9 @@ class ConvertersTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `toQueueStatus throws on invalid string`() {
|
||||
converters.toQueueStatus("NONEXISTENT")
|
||||
@Test
|
||||
fun `toQueueStatus returns NONE fallback on invalid string`() {
|
||||
assertEquals(QueueStatus.NONE, converters.toQueueStatus("NONEXISTENT"))
|
||||
}
|
||||
|
||||
// --- String list JSON serialization ---
|
||||
|
|
|
|||
|
|
@ -265,13 +265,13 @@ class GhostModelsTest {
|
|||
// --- Enum values ---
|
||||
|
||||
@Test
|
||||
fun `PostStatus has exactly 3 values`() {
|
||||
assertEquals(3, PostStatus.values().size)
|
||||
fun `PostStatus has exactly 4 values`() {
|
||||
assertEquals(4, PostStatus.values().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `QueueStatus has exactly 5 values`() {
|
||||
assertEquals(5, QueueStatus.values().size)
|
||||
fun `QueueStatus has exactly 6 values`() {
|
||||
assertEquals(6, QueueStatus.values().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -279,6 +279,7 @@ class GhostModelsTest {
|
|||
assertEquals(PostStatus.DRAFT, PostStatus.valueOf("DRAFT"))
|
||||
assertEquals(PostStatus.PUBLISHED, PostStatus.valueOf("PUBLISHED"))
|
||||
assertEquals(PostStatus.SCHEDULED, PostStatus.valueOf("SCHEDULED"))
|
||||
assertEquals(PostStatus.SENT, PostStatus.valueOf("SENT"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -286,6 +287,7 @@ class GhostModelsTest {
|
|||
assertEquals(QueueStatus.NONE, QueueStatus.valueOf("NONE"))
|
||||
assertEquals(QueueStatus.QUEUED_PUBLISH, QueueStatus.valueOf("QUEUED_PUBLISH"))
|
||||
assertEquals(QueueStatus.QUEUED_SCHEDULED, QueueStatus.valueOf("QUEUED_SCHEDULED"))
|
||||
assertEquals(QueueStatus.QUEUED_EMAIL_ONLY, QueueStatus.valueOf("QUEUED_EMAIL_ONLY"))
|
||||
assertEquals(QueueStatus.UPLOADING, QueueStatus.valueOf("UPLOADING"))
|
||||
assertEquals(QueueStatus.FAILED, QueueStatus.valueOf("FAILED"))
|
||||
}
|
||||
|
|
@ -422,4 +424,95 @@ class GhostModelsTest {
|
|||
val json = gson.toJson(wrapper)
|
||||
assertTrue(json.contains("\"feature_image_alt\":\"Photo description\""))
|
||||
}
|
||||
|
||||
// --- email_only field ---
|
||||
|
||||
@Test
|
||||
fun `GhostPost default email_only is null`() {
|
||||
val post = GhostPost()
|
||||
assertNull(post.email_only)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostPost stores email_only true`() {
|
||||
val post = GhostPost(email_only = true)
|
||||
assertEquals(true, post.email_only)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostPost serializes email_only to JSON`() {
|
||||
val post = GhostPost(title = "Email post", email_only = true)
|
||||
val json = gson.toJson(post)
|
||||
assertTrue(json.contains("\"email_only\":true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostPost deserializes email_only from JSON`() {
|
||||
val json = """{"id":"1","email_only":true}"""
|
||||
val post = gson.fromJson(json, GhostPost::class.java)
|
||||
assertEquals(true, post.email_only)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostPost deserializes with missing email_only`() {
|
||||
val json = """{"id":"1","title":"Test"}"""
|
||||
val post = gson.fromJson(json, GhostPost::class.java)
|
||||
assertNull(post.email_only)
|
||||
}
|
||||
|
||||
// --- FeedPost emailOnly ---
|
||||
|
||||
@Test
|
||||
fun `FeedPost default emailOnly is false`() {
|
||||
val post = FeedPost(
|
||||
title = "Test",
|
||||
textContent = "Content",
|
||||
htmlContent = null,
|
||||
imageUrl = null,
|
||||
linkUrl = null,
|
||||
linkTitle = null,
|
||||
linkDescription = null,
|
||||
linkImageUrl = null,
|
||||
status = "published",
|
||||
publishedAt = null,
|
||||
createdAt = null,
|
||||
updatedAt = null
|
||||
)
|
||||
assertFalse(post.emailOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FeedPost stores emailOnly true`() {
|
||||
val post = FeedPost(
|
||||
title = "Test",
|
||||
textContent = "Content",
|
||||
htmlContent = null,
|
||||
imageUrl = null,
|
||||
linkUrl = null,
|
||||
linkTitle = null,
|
||||
linkDescription = null,
|
||||
linkImageUrl = null,
|
||||
status = "sent",
|
||||
publishedAt = null,
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
emailOnly = true
|
||||
)
|
||||
assertTrue(post.emailOnly)
|
||||
}
|
||||
|
||||
// --- LocalPost emailOnly ---
|
||||
|
||||
@Test
|
||||
fun `LocalPost default emailOnly is false`() {
|
||||
val post = LocalPost()
|
||||
assertFalse(post.emailOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LocalPost stores emailOnly true`() {
|
||||
val post = LocalPost(emailOnly = true, newsletterSlug = "default-newsletter")
|
||||
assertTrue(post.emailOnly)
|
||||
assertEquals("default-newsletter", post.newsletterSlug)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
import com.google.gson.Gson
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class MemberModelsTest {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
@Test
|
||||
fun `GhostMember deserializes from JSON correctly`() {
|
||||
val json = """{
|
||||
"id": "member1",
|
||||
"email": "test@example.com",
|
||||
"name": "John Doe",
|
||||
"status": "free",
|
||||
"avatar_image": "https://example.com/avatar.jpg",
|
||||
"email_count": 10,
|
||||
"email_opened_count": 5,
|
||||
"email_open_rate": 50.0,
|
||||
"last_seen_at": "2026-03-15T10:00:00.000Z",
|
||||
"created_at": "2026-01-01T00:00:00.000Z",
|
||||
"updated_at": "2026-03-15T10:00:00.000Z",
|
||||
"note": "VIP member",
|
||||
"geolocation": "Warsaw, Poland"
|
||||
}"""
|
||||
val member = gson.fromJson(json, GhostMember::class.java)
|
||||
assertEquals("member1", member.id)
|
||||
assertEquals("test@example.com", member.email)
|
||||
assertEquals("John Doe", member.name)
|
||||
assertEquals("free", member.status)
|
||||
assertEquals("https://example.com/avatar.jpg", member.avatar_image)
|
||||
assertEquals(10, member.email_count)
|
||||
assertEquals(5, member.email_opened_count)
|
||||
assertEquals(50.0, member.email_open_rate!!, 0.001)
|
||||
assertEquals("VIP member", member.note)
|
||||
assertEquals("Warsaw, Poland", member.geolocation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostMember deserializes with missing optional fields`() {
|
||||
val json = """{"id": "member2"}"""
|
||||
val member = gson.fromJson(json, GhostMember::class.java)
|
||||
assertEquals("member2", member.id)
|
||||
assertNull(member.email)
|
||||
assertNull(member.name)
|
||||
assertNull(member.status)
|
||||
assertNull(member.avatar_image)
|
||||
assertNull(member.email_count)
|
||||
assertNull(member.email_opened_count)
|
||||
assertNull(member.email_open_rate)
|
||||
assertNull(member.last_seen_at)
|
||||
assertNull(member.created_at)
|
||||
assertNull(member.updated_at)
|
||||
assertNull(member.labels)
|
||||
assertNull(member.newsletters)
|
||||
assertNull(member.subscriptions)
|
||||
assertNull(member.note)
|
||||
assertNull(member.geolocation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostMember deserializes with labels`() {
|
||||
val json = """{
|
||||
"id": "member3",
|
||||
"labels": [
|
||||
{"id": "label1", "name": "VIP", "slug": "vip"},
|
||||
{"id": "label2", "name": "Beta", "slug": "beta"}
|
||||
]
|
||||
}"""
|
||||
val member = gson.fromJson(json, GhostMember::class.java)
|
||||
assertEquals(2, member.labels?.size)
|
||||
assertEquals("VIP", member.labels?.get(0)?.name)
|
||||
assertEquals("vip", member.labels?.get(0)?.slug)
|
||||
assertEquals("Beta", member.labels?.get(1)?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostMember deserializes with newsletters`() {
|
||||
val json = """{
|
||||
"id": "member4",
|
||||
"newsletters": [
|
||||
{"id": "nl1", "name": "Weekly Digest", "slug": "weekly-digest"},
|
||||
{"id": "nl2", "name": "Product Updates", "slug": "product-updates"}
|
||||
]
|
||||
}"""
|
||||
val member = gson.fromJson(json, GhostMember::class.java)
|
||||
assertEquals(2, member.newsletters?.size)
|
||||
assertEquals("Weekly Digest", member.newsletters?.get(0)?.name)
|
||||
assertEquals("nl2", member.newsletters?.get(1)?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostMember deserializes with subscriptions`() {
|
||||
val json = """{
|
||||
"id": "member5",
|
||||
"status": "paid",
|
||||
"subscriptions": [
|
||||
{
|
||||
"id": "sub1",
|
||||
"status": "active",
|
||||
"start_date": "2026-01-01T00:00:00.000Z",
|
||||
"current_period_end": "2026-04-01T00:00:00.000Z",
|
||||
"cancel_at_period_end": false,
|
||||
"price": {"amount": 500, "currency": "USD", "interval": "month"},
|
||||
"tier": {"id": "tier1", "name": "Gold"}
|
||||
}
|
||||
]
|
||||
}"""
|
||||
val member = gson.fromJson(json, GhostMember::class.java)
|
||||
assertEquals("paid", member.status)
|
||||
assertEquals(1, member.subscriptions?.size)
|
||||
val sub = member.subscriptions!![0]
|
||||
assertEquals("sub1", sub.id)
|
||||
assertEquals("active", sub.status)
|
||||
assertEquals(false, sub.cancel_at_period_end)
|
||||
assertEquals(500, sub.price?.amount)
|
||||
assertEquals("USD", sub.price?.currency)
|
||||
assertEquals("month", sub.price?.interval)
|
||||
assertEquals("Gold", sub.tier?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MembersResponse deserializes with members and pagination`() {
|
||||
val json = """{
|
||||
"members": [
|
||||
{"id": "m1", "email": "a@example.com", "name": "Alice", "status": "free"},
|
||||
{"id": "m2", "email": "b@example.com", "name": "Bob", "status": "paid"}
|
||||
],
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": 1, "limit": 15, "pages": 3, "total": 42, "next": 2, "prev": null
|
||||
}
|
||||
}
|
||||
}"""
|
||||
val response = gson.fromJson(json, MembersResponse::class.java)
|
||||
assertEquals(2, response.members.size)
|
||||
assertEquals("m1", response.members[0].id)
|
||||
assertEquals("Alice", response.members[0].name)
|
||||
assertEquals("free", response.members[0].status)
|
||||
assertEquals("Bob", response.members[1].name)
|
||||
assertEquals("paid", response.members[1].status)
|
||||
assertEquals(1, response.meta?.pagination?.page)
|
||||
assertEquals(3, response.meta?.pagination?.pages)
|
||||
assertEquals(42, response.meta?.pagination?.total)
|
||||
assertEquals(2, response.meta?.pagination?.next)
|
||||
assertNull(response.meta?.pagination?.prev)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MembersResponse deserializes with empty members list`() {
|
||||
val json = """{
|
||||
"members": [],
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"page": 1, "limit": 15, "pages": 0, "total": 0, "next": null, "prev": null
|
||||
}
|
||||
}
|
||||
}"""
|
||||
val response = gson.fromJson(json, MembersResponse::class.java)
|
||||
assertTrue(response.members.isEmpty())
|
||||
assertEquals(0, response.meta?.pagination?.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MemberLabel deserializes correctly`() {
|
||||
val json = """{"id": "l1", "name": "Premium", "slug": "premium"}"""
|
||||
val label = gson.fromJson(json, MemberLabel::class.java)
|
||||
assertEquals("l1", label.id)
|
||||
assertEquals("Premium", label.name)
|
||||
assertEquals("premium", label.slug)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MemberLabel allows null id and slug`() {
|
||||
val json = """{"name": "Test"}"""
|
||||
val label = gson.fromJson(json, MemberLabel::class.java)
|
||||
assertNull(label.id)
|
||||
assertEquals("Test", label.name)
|
||||
assertNull(label.slug)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MemberNewsletter deserializes correctly`() {
|
||||
val json = """{"id": "n1", "name": "Daily News", "slug": "daily-news"}"""
|
||||
val newsletter = gson.fromJson(json, MemberNewsletter::class.java)
|
||||
assertEquals("n1", newsletter.id)
|
||||
assertEquals("Daily News", newsletter.name)
|
||||
assertEquals("daily-news", newsletter.slug)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubscriptionPrice deserializes correctly`() {
|
||||
val json = """{"amount": 1000, "currency": "EUR", "interval": "year"}"""
|
||||
val price = gson.fromJson(json, SubscriptionPrice::class.java)
|
||||
assertEquals(1000, price.amount)
|
||||
assertEquals("EUR", price.currency)
|
||||
assertEquals("year", price.interval)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubscriptionTier deserializes correctly`() {
|
||||
val json = """{"id": "t1", "name": "Premium"}"""
|
||||
val tier = gson.fromJson(json, SubscriptionTier::class.java)
|
||||
assertEquals("t1", tier.id)
|
||||
assertEquals("Premium", tier.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostMember serializes to JSON correctly`() {
|
||||
val member = GhostMember(
|
||||
id = "m1",
|
||||
email = "test@test.com",
|
||||
name = "Test User",
|
||||
status = "free",
|
||||
avatar_image = null,
|
||||
email_count = 5,
|
||||
email_opened_count = 3,
|
||||
email_open_rate = 60.0,
|
||||
last_seen_at = null,
|
||||
created_at = "2026-01-01T00:00:00.000Z",
|
||||
updated_at = null,
|
||||
labels = emptyList(),
|
||||
newsletters = emptyList(),
|
||||
subscriptions = null,
|
||||
note = null,
|
||||
geolocation = null
|
||||
)
|
||||
val json = gson.toJson(member)
|
||||
assertTrue(json.contains("\"id\":\"m1\""))
|
||||
assertTrue(json.contains("\"email\":\"test@test.com\""))
|
||||
assertTrue(json.contains("\"status\":\"free\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MembersResponse reuses Meta and Pagination from GhostModels`() {
|
||||
// Verify MembersResponse uses the same Meta/Pagination as PostsResponse
|
||||
val json = """{
|
||||
"members": [{"id": "m1"}],
|
||||
"meta": {"pagination": {"page": 2, "limit": 50, "pages": 5, "total": 250, "next": 3, "prev": 1}}
|
||||
}"""
|
||||
val response = gson.fromJson(json, MembersResponse::class.java)
|
||||
val pagination = response.meta?.pagination!!
|
||||
assertEquals(2, pagination.page)
|
||||
assertEquals(50, pagination.limit)
|
||||
assertEquals(5, pagination.pages)
|
||||
assertEquals(250, pagination.total)
|
||||
assertEquals(3, pagination.next)
|
||||
assertEquals(1, pagination.prev)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostMember with zero email stats`() {
|
||||
val json = """{
|
||||
"id": "m1",
|
||||
"email_count": 0,
|
||||
"email_opened_count": 0,
|
||||
"email_open_rate": null
|
||||
}"""
|
||||
val member = gson.fromJson(json, GhostMember::class.java)
|
||||
assertEquals(0, member.email_count)
|
||||
assertEquals(0, member.email_opened_count)
|
||||
assertNull(member.email_open_rate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MemberSubscription with cancel_at_period_end true`() {
|
||||
val json = """{
|
||||
"id": "sub1",
|
||||
"status": "active",
|
||||
"cancel_at_period_end": true,
|
||||
"price": {"amount": 900, "currency": "USD", "interval": "month"},
|
||||
"tier": {"id": "t1", "name": "Basic"}
|
||||
}"""
|
||||
val sub = gson.fromJson(json, MemberSubscription::class.java)
|
||||
assertEquals(true, sub.cancel_at_period_end)
|
||||
assertEquals(900, sub.price?.amount)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
import com.google.gson.Gson
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PageModelsTest {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
// --- GhostPage defaults ---
|
||||
|
||||
@Test
|
||||
fun `GhostPage all fields default to null`() {
|
||||
val page = GhostPage()
|
||||
assertNull(page.id)
|
||||
assertNull(page.title)
|
||||
assertNull(page.slug)
|
||||
assertNull(page.url)
|
||||
assertNull(page.html)
|
||||
assertNull(page.plaintext)
|
||||
assertNull(page.mobiledoc)
|
||||
assertNull(page.status)
|
||||
assertNull(page.feature_image)
|
||||
assertNull(page.custom_excerpt)
|
||||
assertNull(page.created_at)
|
||||
assertNull(page.updated_at)
|
||||
assertNull(page.published_at)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostPage stores all fields correctly`() {
|
||||
val page = GhostPage(
|
||||
id = "page-1",
|
||||
title = "About",
|
||||
slug = "about",
|
||||
url = "https://blog.example.com/about/",
|
||||
html = "<p>About us</p>",
|
||||
plaintext = "About us",
|
||||
mobiledoc = """{"version":"0.3.1"}""",
|
||||
status = "published",
|
||||
feature_image = "https://blog.example.com/img.jpg",
|
||||
custom_excerpt = "Learn more about us",
|
||||
created_at = "2024-01-01T00:00:00.000Z",
|
||||
updated_at = "2024-06-15T12:00:00.000Z",
|
||||
published_at = "2024-01-02T00:00:00.000Z"
|
||||
)
|
||||
assertEquals("page-1", page.id)
|
||||
assertEquals("About", page.title)
|
||||
assertEquals("about", page.slug)
|
||||
assertEquals("https://blog.example.com/about/", page.url)
|
||||
assertEquals("<p>About us</p>", page.html)
|
||||
assertEquals("About us", page.plaintext)
|
||||
assertEquals("published", page.status)
|
||||
assertEquals("https://blog.example.com/img.jpg", page.feature_image)
|
||||
assertEquals("Learn more about us", page.custom_excerpt)
|
||||
assertEquals("2024-01-01T00:00:00.000Z", page.created_at)
|
||||
assertEquals("2024-06-15T12:00:00.000Z", page.updated_at)
|
||||
assertEquals("2024-01-02T00:00:00.000Z", page.published_at)
|
||||
}
|
||||
|
||||
// --- GSON serialization ---
|
||||
|
||||
@Test
|
||||
fun `GhostPage serializes to JSON correctly`() {
|
||||
val page = GhostPage(
|
||||
id = "abc123",
|
||||
title = "Contact",
|
||||
slug = "contact",
|
||||
status = "published"
|
||||
)
|
||||
val json = gson.toJson(page)
|
||||
assertTrue(json.contains("\"id\":\"abc123\""))
|
||||
assertTrue(json.contains("\"title\":\"Contact\""))
|
||||
assertTrue(json.contains("\"slug\":\"contact\""))
|
||||
assertTrue(json.contains("\"status\":\"published\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostPage deserializes from JSON correctly`() {
|
||||
val json = """{"id":"xyz","title":"FAQ","slug":"faq","status":"draft"}"""
|
||||
val page = gson.fromJson(json, GhostPage::class.java)
|
||||
assertEquals("xyz", page.id)
|
||||
assertEquals("FAQ", page.title)
|
||||
assertEquals("faq", page.slug)
|
||||
assertEquals("draft", page.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostPage deserializes with missing optional fields`() {
|
||||
val json = """{"id":"test"}"""
|
||||
val page = gson.fromJson(json, GhostPage::class.java)
|
||||
assertEquals("test", page.id)
|
||||
assertNull(page.title)
|
||||
assertNull(page.slug)
|
||||
assertNull(page.html)
|
||||
assertNull(page.status)
|
||||
}
|
||||
|
||||
// --- PagesResponse ---
|
||||
|
||||
@Test
|
||||
fun `PagesResponse deserializes with pages and pagination`() {
|
||||
val json = """{
|
||||
"pages": [{"id": "1", "title": "About"}, {"id": "2", "title": "Contact"}],
|
||||
"meta": {"pagination": {"page": 1, "limit": 15, "pages": 1, "total": 2, "next": null, "prev": null}}
|
||||
}"""
|
||||
val response = gson.fromJson(json, PagesResponse::class.java)
|
||||
assertEquals(2, response.pages.size)
|
||||
assertEquals("1", response.pages[0].id)
|
||||
assertEquals("About", response.pages[0].title)
|
||||
assertEquals("2", response.pages[1].id)
|
||||
assertEquals("Contact", response.pages[1].title)
|
||||
assertEquals(1, response.meta?.pagination?.page)
|
||||
assertEquals(2, response.meta?.pagination?.total)
|
||||
assertNull(response.meta?.pagination?.next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PagesResponse deserializes with empty pages list`() {
|
||||
val json = """{"pages": [], "meta": null}"""
|
||||
val response = gson.fromJson(json, PagesResponse::class.java)
|
||||
assertTrue(response.pages.isEmpty())
|
||||
assertNull(response.meta)
|
||||
}
|
||||
|
||||
// --- PageWrapper ---
|
||||
|
||||
@Test
|
||||
fun `PageWrapper wraps pages for API request`() {
|
||||
val wrapper = PageWrapper(listOf(GhostPage(title = "New Page", status = "draft")))
|
||||
val json = gson.toJson(wrapper)
|
||||
assertTrue(json.contains("\"pages\""))
|
||||
assertTrue(json.contains("\"New Page\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PageWrapper serializes single page correctly`() {
|
||||
val page = GhostPage(
|
||||
title = "About Us",
|
||||
slug = "about-us",
|
||||
status = "published",
|
||||
mobiledoc = """{"version":"0.3.1","atoms":[],"cards":[],"markups":[],"sections":[[1,"p",[[0,[],0,"Welcome"]]]]}"""
|
||||
)
|
||||
val wrapper = PageWrapper(listOf(page))
|
||||
val json = gson.toJson(wrapper)
|
||||
assertTrue(json.contains("\"title\":\"About Us\""))
|
||||
assertTrue(json.contains("\"slug\":\"about-us\""))
|
||||
assertTrue(json.contains("\"status\":\"published\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostPage updated_at is preserved for PUT requests`() {
|
||||
val page = GhostPage(
|
||||
id = "page-1",
|
||||
title = "Updated Title",
|
||||
updated_at = "2024-06-15T12:00:00.000Z"
|
||||
)
|
||||
val json = gson.toJson(page)
|
||||
assertTrue(json.contains("\"updated_at\":\"2024-06-15T12:00:00.000Z\""))
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,8 @@ class PostFilterTest {
|
|||
// --- Enum values ---
|
||||
|
||||
@Test
|
||||
fun `PostFilter has exactly 4 values`() {
|
||||
assertEquals(4, PostFilter.values().size)
|
||||
fun `PostFilter has exactly 5 values`() {
|
||||
assertEquals(5, PostFilter.values().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -18,6 +18,7 @@ class PostFilterTest {
|
|||
assertEquals(PostFilter.PUBLISHED, PostFilter.valueOf("PUBLISHED"))
|
||||
assertEquals(PostFilter.DRAFT, PostFilter.valueOf("DRAFT"))
|
||||
assertEquals(PostFilter.SCHEDULED, PostFilter.valueOf("SCHEDULED"))
|
||||
assertEquals(PostFilter.SENT, PostFilter.valueOf("SENT"))
|
||||
}
|
||||
|
||||
// --- Display names ---
|
||||
|
|
@ -107,4 +108,26 @@ class PostFilterTest {
|
|||
fun `SCHEDULED emptyMessage returns No scheduled posts yet`() {
|
||||
assertEquals("No scheduled posts yet", PostFilter.SCHEDULED.emptyMessage())
|
||||
}
|
||||
|
||||
// --- SENT filter ---
|
||||
|
||||
@Test
|
||||
fun `SENT displayName is Sent`() {
|
||||
assertEquals("Sent", PostFilter.SENT.displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SENT ghostFilter is status_sent`() {
|
||||
assertEquals("status:sent", PostFilter.SENT.ghostFilter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SENT toPostStatus returns PostStatus SENT`() {
|
||||
assertEquals(PostStatus.SENT, PostFilter.SENT.toPostStatus())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SENT emptyMessage returns No sent newsletters yet`() {
|
||||
assertEquals("No sent newsletters yet", PostFilter.SENT.emptyMessage())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
import com.google.gson.Gson
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class SiteModelsTest {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
@Test
|
||||
fun `deserialize full site response`() {
|
||||
val json = """
|
||||
{
|
||||
"title": "My Ghost Blog",
|
||||
"description": "A blog about things",
|
||||
"logo": "https://example.com/logo.png",
|
||||
"icon": "https://example.com/icon.png",
|
||||
"accent_color": "#ff1a75",
|
||||
"url": "https://example.com/",
|
||||
"version": "5.82.0",
|
||||
"locale": "en"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val site = gson.fromJson(json, GhostSite::class.java)
|
||||
|
||||
assertEquals("My Ghost Blog", site.title)
|
||||
assertEquals("A blog about things", site.description)
|
||||
assertEquals("https://example.com/logo.png", site.logo)
|
||||
assertEquals("https://example.com/icon.png", site.icon)
|
||||
assertEquals("#ff1a75", site.accent_color)
|
||||
assertEquals("https://example.com/", site.url)
|
||||
assertEquals("5.82.0", site.version)
|
||||
assertEquals("en", site.locale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize site response with null fields`() {
|
||||
val json = """
|
||||
{
|
||||
"title": "Minimal Blog",
|
||||
"url": "https://minimal.ghost.io/"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val site = gson.fromJson(json, GhostSite::class.java)
|
||||
|
||||
assertEquals("Minimal Blog", site.title)
|
||||
assertNull(site.description)
|
||||
assertNull(site.logo)
|
||||
assertNull(site.icon)
|
||||
assertNull(site.accent_color)
|
||||
assertEquals("https://minimal.ghost.io/", site.url)
|
||||
assertNull(site.version)
|
||||
assertNull(site.locale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `version parsing extracts major version`() {
|
||||
val site = GhostSite(
|
||||
title = "Test",
|
||||
description = null,
|
||||
logo = null,
|
||||
icon = null,
|
||||
accent_color = null,
|
||||
url = null,
|
||||
version = "5.82.0",
|
||||
locale = null
|
||||
)
|
||||
|
||||
val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull()
|
||||
assertEquals(5, majorVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `version parsing handles old version`() {
|
||||
val site = GhostSite(
|
||||
title = "Old Blog",
|
||||
description = null,
|
||||
logo = null,
|
||||
icon = null,
|
||||
accent_color = null,
|
||||
url = null,
|
||||
version = "4.48.9",
|
||||
locale = null
|
||||
)
|
||||
|
||||
val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull()
|
||||
assertEquals(4, majorVersion)
|
||||
assertTrue((majorVersion ?: 0) < 5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `version parsing handles null version`() {
|
||||
val site = GhostSite(
|
||||
title = "No Version",
|
||||
description = null,
|
||||
logo = null,
|
||||
icon = null,
|
||||
accent_color = null,
|
||||
url = null,
|
||||
version = null,
|
||||
locale = null
|
||||
)
|
||||
|
||||
val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull()
|
||||
assertNull(majorVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serialize and deserialize round trip`() {
|
||||
val original = GhostSite(
|
||||
title = "Round Trip Blog",
|
||||
description = "Testing serialization",
|
||||
logo = "https://example.com/logo.png",
|
||||
icon = "https://example.com/icon.png",
|
||||
accent_color = "#15171a",
|
||||
url = "https://example.com/",
|
||||
version = "5.82.0",
|
||||
locale = "pl"
|
||||
)
|
||||
|
||||
val json = gson.toJson(original)
|
||||
val deserialized = gson.fromJson(json, GhostSite::class.java)
|
||||
|
||||
assertEquals(original, deserialized)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
import com.google.gson.Gson
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class TagModelsTest {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
// --- GhostTagFull defaults ---
|
||||
|
||||
@Test
|
||||
fun `GhostTagFull default id is null`() {
|
||||
val tag = GhostTagFull(name = "test")
|
||||
assertNull(tag.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostTagFull default visibility is public`() {
|
||||
val tag = GhostTagFull(name = "test")
|
||||
assertEquals("public", tag.visibility)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostTagFull default optional fields are null`() {
|
||||
val tag = GhostTagFull(name = "test")
|
||||
assertNull(tag.slug)
|
||||
assertNull(tag.description)
|
||||
assertNull(tag.feature_image)
|
||||
assertNull(tag.accent_color)
|
||||
assertNull(tag.count)
|
||||
assertNull(tag.created_at)
|
||||
assertNull(tag.updated_at)
|
||||
assertNull(tag.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostTagFull stores all fields`() {
|
||||
val tag = GhostTagFull(
|
||||
id = "tag-1",
|
||||
name = "kotlin",
|
||||
slug = "kotlin",
|
||||
description = "Posts about Kotlin",
|
||||
feature_image = "https://example.com/kotlin.png",
|
||||
visibility = "public",
|
||||
accent_color = "#FF5722",
|
||||
count = TagCount(posts = 42),
|
||||
created_at = "2024-01-01T00:00:00.000Z",
|
||||
updated_at = "2024-06-15T12:00:00.000Z",
|
||||
url = "https://blog.example.com/tag/kotlin/"
|
||||
)
|
||||
assertEquals("tag-1", tag.id)
|
||||
assertEquals("kotlin", tag.name)
|
||||
assertEquals("kotlin", tag.slug)
|
||||
assertEquals("Posts about Kotlin", tag.description)
|
||||
assertEquals("https://example.com/kotlin.png", tag.feature_image)
|
||||
assertEquals("public", tag.visibility)
|
||||
assertEquals("#FF5722", tag.accent_color)
|
||||
assertEquals(42, tag.count?.posts)
|
||||
assertEquals("2024-01-01T00:00:00.000Z", tag.created_at)
|
||||
assertEquals("2024-06-15T12:00:00.000Z", tag.updated_at)
|
||||
assertEquals("https://blog.example.com/tag/kotlin/", tag.url)
|
||||
}
|
||||
|
||||
// --- TagCount ---
|
||||
|
||||
@Test
|
||||
fun `TagCount stores post count`() {
|
||||
val count = TagCount(posts = 10)
|
||||
assertEquals(10, count.posts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TagCount allows null posts`() {
|
||||
val count = TagCount(posts = null)
|
||||
assertNull(count.posts)
|
||||
}
|
||||
|
||||
// --- GSON serialization ---
|
||||
|
||||
@Test
|
||||
fun `GhostTagFull serializes to JSON correctly`() {
|
||||
val tag = GhostTagFull(
|
||||
name = "android",
|
||||
description = "Android development",
|
||||
accent_color = "#3DDC84"
|
||||
)
|
||||
val json = gson.toJson(tag)
|
||||
assertTrue(json.contains("\"name\":\"android\""))
|
||||
assertTrue(json.contains("\"description\":\"Android development\""))
|
||||
assertTrue(json.contains("\"accent_color\":\"#3DDC84\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostTagFull deserializes from JSON correctly`() {
|
||||
val json = """{
|
||||
"id": "abc123",
|
||||
"name": "tech",
|
||||
"slug": "tech",
|
||||
"description": "Technology posts",
|
||||
"visibility": "public",
|
||||
"accent_color": "#1E88E5",
|
||||
"count": {"posts": 15},
|
||||
"created_at": "2024-01-01T00:00:00.000Z",
|
||||
"updated_at": "2024-06-01T00:00:00.000Z",
|
||||
"url": "https://blog.example.com/tag/tech/"
|
||||
}"""
|
||||
val tag = gson.fromJson(json, GhostTagFull::class.java)
|
||||
assertEquals("abc123", tag.id)
|
||||
assertEquals("tech", tag.name)
|
||||
assertEquals("tech", tag.slug)
|
||||
assertEquals("Technology posts", tag.description)
|
||||
assertEquals("public", tag.visibility)
|
||||
assertEquals("#1E88E5", tag.accent_color)
|
||||
assertEquals(15, tag.count?.posts)
|
||||
assertEquals("2024-01-01T00:00:00.000Z", tag.created_at)
|
||||
assertEquals("2024-06-01T00:00:00.000Z", tag.updated_at)
|
||||
assertEquals("https://blog.example.com/tag/tech/", tag.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostTagFull deserializes with missing optional fields`() {
|
||||
val json = """{"name": "minimal"}"""
|
||||
val tag = gson.fromJson(json, GhostTagFull::class.java)
|
||||
assertEquals("minimal", tag.name)
|
||||
assertNull(tag.id)
|
||||
assertNull(tag.slug)
|
||||
assertNull(tag.description)
|
||||
assertNull(tag.accent_color)
|
||||
assertNull(tag.count)
|
||||
}
|
||||
|
||||
// --- TagsResponse ---
|
||||
|
||||
@Test
|
||||
fun `TagsResponse deserializes with tags and meta`() {
|
||||
val json = """{
|
||||
"tags": [
|
||||
{"id": "1", "name": "news", "slug": "news", "count": {"posts": 5}},
|
||||
{"id": "2", "name": "tech", "slug": "tech", "count": {"posts": 12}}
|
||||
],
|
||||
"meta": {"pagination": {"page": 1, "limit": 15, "pages": 1, "total": 2, "next": null, "prev": null}}
|
||||
}"""
|
||||
val response = gson.fromJson(json, TagsResponse::class.java)
|
||||
assertEquals(2, response.tags.size)
|
||||
assertEquals("news", response.tags[0].name)
|
||||
assertEquals(5, response.tags[0].count?.posts)
|
||||
assertEquals("tech", response.tags[1].name)
|
||||
assertEquals(12, response.tags[1].count?.posts)
|
||||
assertNotNull(response.meta)
|
||||
assertEquals(1, response.meta?.pagination?.page)
|
||||
assertEquals(2, response.meta?.pagination?.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TagsResponse deserializes with null meta`() {
|
||||
val json = """{"tags": [{"name": "solo"}], "meta": null}"""
|
||||
val response = gson.fromJson(json, TagsResponse::class.java)
|
||||
assertEquals(1, response.tags.size)
|
||||
assertNull(response.meta)
|
||||
}
|
||||
|
||||
// --- TagWrapper ---
|
||||
|
||||
@Test
|
||||
fun `TagWrapper wraps tags for API request`() {
|
||||
val wrapper = TagWrapper(listOf(GhostTagFull(name = "new-tag", description = "A new tag")))
|
||||
val json = gson.toJson(wrapper)
|
||||
assertTrue(json.contains("\"tags\""))
|
||||
assertTrue(json.contains("\"new-tag\""))
|
||||
assertTrue(json.contains("\"A new tag\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TagWrapper serializes accent_color`() {
|
||||
val wrapper = TagWrapper(listOf(GhostTagFull(
|
||||
name = "colored",
|
||||
accent_color = "#FF0000"
|
||||
)))
|
||||
val json = gson.toJson(wrapper)
|
||||
assertTrue(json.contains("\"accent_color\":\"#FF0000\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TagCount zero posts`() {
|
||||
val count = TagCount(posts = 0)
|
||||
assertEquals(0, count.posts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GhostTagFull with internal visibility`() {
|
||||
val tag = GhostTagFull(name = "internal-tag", visibility = "internal")
|
||||
assertEquals("internal", tag.visibility)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
package com.swoosh.microblog.data.repository
|
||||
|
||||
import com.swoosh.microblog.data.model.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class MemberRepositoryTest {
|
||||
|
||||
// We test getMemberStats() which is a pure function — no Context needed
|
||||
private fun createRepository(): MemberRepository? = null // Can't instantiate without Context
|
||||
|
||||
private fun getMemberStats(members: List<GhostMember>): MemberStats {
|
||||
// Replicate the pure function logic to test it directly
|
||||
// We use a standalone helper since the repository needs Context
|
||||
val total = members.size
|
||||
val free = members.count { it.status == "free" }
|
||||
val paid = members.count { it.status == "paid" }
|
||||
|
||||
val oneWeekAgo = Instant.now().minus(7, ChronoUnit.DAYS)
|
||||
val newThisWeek = members.count { member ->
|
||||
member.created_at?.let {
|
||||
try {
|
||||
Instant.parse(it).isAfter(oneWeekAgo)
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
|
||||
val openRates = members.mapNotNull { it.email_open_rate }
|
||||
val avgOpenRate = if (openRates.isNotEmpty()) openRates.average() else null
|
||||
|
||||
val mrr = members.filter { it.status == "paid" }.sumOf { member ->
|
||||
member.subscriptions?.sumOf { sub ->
|
||||
if (sub.status == "active") {
|
||||
val amount = sub.price?.amount ?: 0
|
||||
when (sub.price?.interval) {
|
||||
"year" -> amount / 12
|
||||
"month" -> amount
|
||||
else -> amount
|
||||
}
|
||||
} else 0
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
return MemberStats(
|
||||
total = total, free = free, paid = paid,
|
||||
newThisWeek = newThisWeek, avgOpenRate = avgOpenRate, mrr = mrr
|
||||
)
|
||||
}
|
||||
|
||||
private fun makeMember(
|
||||
id: String = "m1",
|
||||
email: String? = null,
|
||||
name: String? = null,
|
||||
status: String? = "free",
|
||||
openRate: Double? = null,
|
||||
createdAt: String? = null,
|
||||
subscriptions: List<MemberSubscription>? = null
|
||||
) = GhostMember(
|
||||
id = id, email = email, name = name, status = status,
|
||||
avatar_image = null, email_count = null, email_opened_count = null,
|
||||
email_open_rate = openRate, last_seen_at = null,
|
||||
created_at = createdAt, updated_at = null,
|
||||
labels = null, newsletters = null, subscriptions = subscriptions,
|
||||
note = null, geolocation = null
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `getMemberStats with empty list returns zero stats`() {
|
||||
val stats = getMemberStats(emptyList())
|
||||
assertEquals(0, stats.total)
|
||||
assertEquals(0, stats.free)
|
||||
assertEquals(0, stats.paid)
|
||||
assertEquals(0, stats.newThisWeek)
|
||||
assertNull(stats.avgOpenRate)
|
||||
assertEquals(0, stats.mrr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats counts total members`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1"),
|
||||
makeMember(id = "m2"),
|
||||
makeMember(id = "m3")
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(3, stats.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats counts free and paid members`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", status = "free"),
|
||||
makeMember(id = "m2", status = "free"),
|
||||
makeMember(id = "m3", status = "paid"),
|
||||
makeMember(id = "m4", status = "paid"),
|
||||
makeMember(id = "m5", status = "paid")
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(5, stats.total)
|
||||
assertEquals(2, stats.free)
|
||||
assertEquals(3, stats.paid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats counts new members this week`() {
|
||||
val recentDate = Instant.now().minus(2, ChronoUnit.DAYS).toString()
|
||||
val oldDate = Instant.now().minus(30, ChronoUnit.DAYS).toString()
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", createdAt = recentDate),
|
||||
makeMember(id = "m2", createdAt = recentDate),
|
||||
makeMember(id = "m3", createdAt = oldDate)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(2, stats.newThisWeek)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles null created_at for new this week`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", createdAt = null),
|
||||
makeMember(id = "m2", createdAt = null)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(0, stats.newThisWeek)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles invalid created_at date`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", createdAt = "not-a-date")
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(0, stats.newThisWeek)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats calculates average open rate`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", openRate = 40.0),
|
||||
makeMember(id = "m2", openRate = 60.0),
|
||||
makeMember(id = "m3", openRate = 80.0)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertNotNull(stats.avgOpenRate)
|
||||
assertEquals(60.0, stats.avgOpenRate!!, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats skips null open rates in average`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", openRate = 50.0),
|
||||
makeMember(id = "m2", openRate = null),
|
||||
makeMember(id = "m3", openRate = 100.0)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertNotNull(stats.avgOpenRate)
|
||||
assertEquals(75.0, stats.avgOpenRate!!, 0.001) // (50 + 100) / 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats returns null avgOpenRate when all rates are null`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", openRate = null),
|
||||
makeMember(id = "m2", openRate = null)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertNull(stats.avgOpenRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats calculates MRR from monthly subscriptions`() {
|
||||
val members = listOf(
|
||||
makeMember(
|
||||
id = "m1", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
|
||||
tier = null
|
||||
)
|
||||
)
|
||||
),
|
||||
makeMember(
|
||||
id = "m2", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s2", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = 1000, currency = "USD", interval = "month"),
|
||||
tier = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(1500, stats.mrr) // 500 + 1000
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats converts yearly subscriptions to monthly MRR`() {
|
||||
val members = listOf(
|
||||
makeMember(
|
||||
id = "m1", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = 12000, currency = "USD", interval = "year"),
|
||||
tier = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(1000, stats.mrr) // 12000 / 12
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats ignores canceled subscriptions in MRR`() {
|
||||
val members = listOf(
|
||||
makeMember(
|
||||
id = "m1", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "canceled", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = true,
|
||||
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
|
||||
tier = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(0, stats.mrr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats ignores free members in MRR calculation`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", status = "free"),
|
||||
makeMember(
|
||||
id = "m2", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
|
||||
tier = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(500, stats.mrr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles paid member with no subscriptions`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", status = "paid", subscriptions = null)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(1, stats.paid)
|
||||
assertEquals(0, stats.mrr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles paid member with empty subscriptions`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", status = "paid", subscriptions = emptyList())
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(1, stats.paid)
|
||||
assertEquals(0, stats.mrr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats mixed free and paid members comprehensive`() {
|
||||
val recentDate = Instant.now().minus(1, ChronoUnit.DAYS).toString()
|
||||
val oldDate = Instant.now().minus(60, ChronoUnit.DAYS).toString()
|
||||
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", status = "free", openRate = 40.0, createdAt = recentDate),
|
||||
makeMember(id = "m2", status = "free", openRate = 60.0, createdAt = oldDate),
|
||||
makeMember(
|
||||
id = "m3", status = "paid", openRate = 80.0, createdAt = recentDate,
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
|
||||
tier = SubscriptionTier(id = "t1", name = "Gold")
|
||||
)
|
||||
)
|
||||
),
|
||||
makeMember(
|
||||
id = "m4", status = "paid", openRate = null, createdAt = oldDate,
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s2", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = 6000, currency = "USD", interval = "year"),
|
||||
tier = SubscriptionTier(id = "t1", name = "Gold")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(4, stats.total)
|
||||
assertEquals(2, stats.free)
|
||||
assertEquals(2, stats.paid)
|
||||
assertEquals(2, stats.newThisWeek) // m1 and m3
|
||||
assertEquals(60.0, stats.avgOpenRate!!, 0.001) // (40 + 60 + 80) / 3
|
||||
assertEquals(1000, stats.mrr) // 500 + 6000/12 = 500 + 500
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles null status members`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", status = null),
|
||||
makeMember(id = "m2", status = "free")
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(2, stats.total)
|
||||
assertEquals(1, stats.free)
|
||||
assertEquals(0, stats.paid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles subscription with null price`() {
|
||||
val members = listOf(
|
||||
makeMember(
|
||||
id = "m1", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = null, tier = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(0, stats.mrr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles subscription with null amount`() {
|
||||
val members = listOf(
|
||||
makeMember(
|
||||
id = "m1", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = null, currency = "USD", interval = "month"),
|
||||
tier = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(0, stats.mrr)
|
||||
}
|
||||
}
|
||||
1443
docs/superpowers/plans/2026-03-19-ghost-api-features.md
Normal file
1443
docs/superpowers/plans/2026-03-19-ghost-api-features.md
Normal file
File diff suppressed because it is too large
Load diff
BIN
pics/feed.png
Normal file
BIN
pics/feed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
BIN
pics/newsletter.png
Normal file
BIN
pics/newsletter.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
BIN
pics/settings.png
Normal file
BIN
pics/settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
BIN
pics/stats.png
Normal file
BIN
pics/stats.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
Loading…
Reference in a new issue