mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-04-01 04:15:42 +00:00
Compare commits
No commits in common. "claude/ghost-microblog-android-utau1" and "v0.2.0" have entirely different histories.
claude/gho
...
v0.2.0
63 changed files with 365 additions and 9476 deletions
29
CLAUDE.md
29
CLAUDE.md
|
|
@ -27,24 +27,9 @@ 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/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/db/`** — Room database with `LocalPost` entity and `LocalPostDao`
|
||||||
- **`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/model/`** — Three model layers: `GhostPost` (API), `LocalPost` (Room entity), `FeedPost` (UI display). Enums: `PostStatus`, `QueueStatus`
|
||||||
- **`data/repository/`** — `PostRepository` coordinates local DB and remote API; `OpenGraphFetcher` parses link previews via Jsoup
|
- **`data/repository/`** — `PostRepository` coordinates local DB and remote API; `OpenGraphFetcher` parses link previews via Jsoup
|
||||||
- **`data/`** (root utilities) — `AccountManager` (multi-account, up to 5), `CredentialsManager`, `FeedPreferences`, `HashtagParser`, `MobiledocBuilder`, `ShareUtils`, `UrlNormalizer`
|
- **`ui/`** — Jetpack Compose screens (Feed, Composer, Detail, Setup, Settings) with ViewModels using `StateFlow`
|
||||||
- **`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
|
- **`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.
|
**Key data flow:** Posts are saved to Room first → queued for upload → `PostUploadWorker` syncs to Ghost API when network is available.
|
||||||
|
|
@ -55,10 +40,6 @@ 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)
|
- **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`
|
- **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
|
- **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**
|
- **Min SDK 26, Target/Compile SDK 34, Kotlin 1.9.22, Java 17**
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
@ -74,10 +55,6 @@ Version is defined in `app/build.gradle.kts` (`versionCode` and `versionName`).
|
||||||
- **MINOR** (0.2.0 → 0.3.0): New features, UI changes, significant improvements
|
- **MINOR** (0.2.0 → 0.3.0): New features, UI changes, significant improvements
|
||||||
- **MAJOR** (0.x → 1.0): First stable public release
|
- **MAJOR** (0.x → 1.0): First stable public release
|
||||||
|
|
||||||
**Current:** `versionName = "0.3.0"`, `versionCode = 3`
|
**Current:** `versionName = "0.2.0"`, `versionCode = 2`
|
||||||
|
|
||||||
**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.
|
**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,35 +7,21 @@ A native Android microblogging client for [Ghost CMS](https://ghost.org). Write,
|
||||||
[](https://developer.android.com/jetpack/compose)
|
[](https://developer.android.com/jetpack/compose)
|
||||||
[](LICENSE)
|
[](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
|
## Features
|
||||||
|
|
||||||
- **Ghost Admin API** — Full integration via JWT authentication (HS256)
|
- **Ghost Admin API** — Full integration via JWT authentication (HS256)
|
||||||
- **Offline-first** — Posts are saved locally and synced when connectivity returns
|
- **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
|
- **Image uploads** — Attach photos from your gallery or camera
|
||||||
- **Link previews** — Automatic Open Graph metadata extraction (title, description, thumbnail)
|
- **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
|
- **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
|
- **Mobiledoc format** — Native Ghost content format with text paragraphs and bookmark cards
|
||||||
- **Encrypted credentials** — API keys stored with AES-256-GCM via AndroidX Security
|
- **Encrypted credentials** — API keys stored with AES-256-GCM via AndroidX Security
|
||||||
- **Background sync** — WorkManager handles upload queue with exponential backoff
|
- **Background sync** — WorkManager handles upload queue with exponential backoff
|
||||||
- **Material 3 UI** — Clean, green-tinted design with polished animations and Light/Dark/System themes
|
- **Material 3 UI** — Clean, modern interface built entirely with Jetpack Compose
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
> Coming soon — contributions welcome!
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -46,31 +32,21 @@ com.swoosh.microblog/
|
||||||
├── data/
|
├── data/
|
||||||
│ ├── api/ # Retrofit client, JWT auth, interceptors
|
│ ├── api/ # Retrofit client, JWT auth, interceptors
|
||||||
│ ├── db/ # Room database, DAOs, type converters
|
│ ├── db/ # Room database, DAOs, type converters
|
||||||
│ ├── model/ # GhostPost (API), LocalPost (DB), FeedPost (UI), PostStats, GhostAccount
|
│ ├── model/ # GhostPost (API), LocalPost (DB), FeedPost (UI)
|
||||||
│ └── repository/ # PostRepository, OpenGraphFetcher
|
│ └── repository/ # PostRepository, OpenGraphFetcher
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── animation/ # SwooshMotion shared animation specs
|
│ ├── feed/ # Post list with pull-to-refresh
|
||||||
│ ├── components/ # Reusable composables (dialogs, placeholders)
|
│ ├── composer/ # Post creation and editing
|
||||||
│ ├── composer/ # Post creation with images, links, hashtags, scheduling
|
│ ├── detail/ # Full post view
|
||||||
│ ├── detail/ # Full post view with pin toggle
|
│ ├── setup/ # Initial configuration wizard
|
||||||
│ ├── feed/ # Post feed with search and filtering
|
│ ├── settings/ # App settings and logout
|
||||||
│ ├── members/ # Ghost members/subscribers management
|
│ ├── navigation/ # Compose Navigation graph
|
||||||
│ ├── newsletter/ # Newsletter configuration and subscriber info
|
│ └── theme/ # Material 3 theming
|
||||||
│ ├── 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)
|
└── 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.
|
**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
|
## Getting started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
@ -90,7 +66,7 @@ com.swoosh.microblog/
|
||||||
### Build and run
|
### Build and run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/nicekid1/Swoosh.git
|
git clone https://github.com/pawelorzech/Swoosh.git
|
||||||
cd Swoosh
|
cd Swoosh
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
```
|
```
|
||||||
|
|
@ -113,7 +89,7 @@ The project includes unit tests with JUnit 4 and Robolectric:
|
||||||
./gradlew app:testDebugUnitTest # Debug variant only
|
./gradlew app:testDebugUnitTest # Debug variant only
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Test coverage includes JWT generation, mobiledoc building, URL normalization, data model serialization, auth interceptors, and time formatting.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
|
|
@ -124,7 +100,6 @@ The project includes unit tests with JUnit 4 and Robolectric:
|
||||||
| Architecture | MVVM, StateFlow, Repository pattern |
|
| Architecture | MVVM, StateFlow, Repository pattern |
|
||||||
| Networking | Retrofit 2, OkHttp 4 |
|
| Networking | Retrofit 2, OkHttp 4 |
|
||||||
| Images | Coil |
|
| Images | Coil |
|
||||||
| Media | Media3 ExoPlayer |
|
|
||||||
| Database | Room |
|
| Database | Room |
|
||||||
| Background | WorkManager |
|
| Background | WorkManager |
|
||||||
| Auth | JJWT (HS256), EncryptedSharedPreferences |
|
| Auth | JJWT (HS256), EncryptedSharedPreferences |
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ android {
|
||||||
applicationId = "com.swoosh.microblog"
|
applicationId = "com.swoosh.microblog"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 3
|
versionCode = 2
|
||||||
versionName = "0.3.0"
|
versionName = "0.2.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables { useSupportLibrary = true }
|
vectorDrawables { useSupportLibrary = true }
|
||||||
|
|
@ -107,10 +107,6 @@ dependencies {
|
||||||
// Jsoup for OpenGraph parsing
|
// Jsoup for OpenGraph parsing
|
||||||
implementation("org.jsoup:jsoup:1.17.2")
|
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
|
// Testing
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||||
|
|
|
||||||
|
|
@ -9,27 +9,59 @@ import com.swoosh.microblog.data.model.LinkPreview
|
||||||
object MobiledocBuilder {
|
object MobiledocBuilder {
|
||||||
|
|
||||||
fun build(text: String, linkPreview: LinkPreview?): String {
|
fun build(text: String, linkPreview: LinkPreview?): String {
|
||||||
return build(text, linkUrl = linkPreview?.url, linkTitle = linkPreview?.title, linkDescription = linkPreview?.description)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds mobiledoc JSON with support for multiple images (with optional alt text on the first),
|
* Build with a single image URL and optional alt text (HEAD's 6-param overload).
|
||||||
* optional video, optional audio, an optional link preview, and an optional file attachment.
|
|
||||||
*
|
|
||||||
* Card order: images -> video -> audio -> bookmark -> file
|
|
||||||
*/
|
*/
|
||||||
fun build(
|
fun build(
|
||||||
text: String,
|
text: String,
|
||||||
imageUrls: List<String> = emptyList(),
|
linkUrl: String?,
|
||||||
linkUrl: String? = null,
|
linkTitle: String?,
|
||||||
linkTitle: String? = null,
|
linkDescription: String?,
|
||||||
linkDescription: String? = null,
|
imageUrl: String?,
|
||||||
imageAlt: String? = null,
|
imageAlt: String?
|
||||||
videoUrl: String? = null,
|
): String {
|
||||||
audioUrl: String? = null,
|
val imageUrls = if (imageUrl != null) listOf(imageUrl) else emptyList()
|
||||||
fileUrl: String? = null,
|
return build(text, imageUrls, linkUrl, linkTitle, linkDescription, imageAlt)
|
||||||
fileName: String? = null,
|
}
|
||||||
fileSize: Long = 0
|
|
||||||
|
/**
|
||||||
|
* 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?
|
||||||
): String {
|
): String {
|
||||||
val escapedText = escapeForJson(text).replace("\n", "\\n")
|
val escapedText = escapeForJson(text).replace("\n", "\\n")
|
||||||
|
|
||||||
|
|
@ -45,20 +77,6 @@ object MobiledocBuilder {
|
||||||
cardSections.add("[10,${cards.size - 1}]")
|
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
|
// Add bookmark card if link is present
|
||||||
if (linkUrl != null) {
|
if (linkUrl != null) {
|
||||||
val escapedUrl = escapeForJson(linkUrl)
|
val escapedUrl = escapeForJson(linkUrl)
|
||||||
|
|
@ -68,14 +86,6 @@ object MobiledocBuilder {
|
||||||
cardSections.add("[10,${cards.size - 1}]")
|
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 cardsJson = cards.joinToString(",")
|
||||||
val cardSectionsJson = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else ""
|
val cardSectionsJson = if (cardSections.isNotEmpty()) "," + cardSections.joinToString(",") else ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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,10 +16,3 @@ object UrlNormalizer {
|
||||||
return normalized
|
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,16 +1,7 @@
|
||||||
package com.swoosh.microblog.data.api
|
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.PostWrapper
|
||||||
import com.swoosh.microblog.data.model.PostsResponse
|
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.MultipartBody
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
|
|
@ -31,18 +22,14 @@ interface GhostApiService {
|
||||||
@POST("ghost/api/admin/posts/")
|
@POST("ghost/api/admin/posts/")
|
||||||
@Headers("Content-Type: application/json")
|
@Headers("Content-Type: application/json")
|
||||||
suspend fun createPost(
|
suspend fun createPost(
|
||||||
@Body body: PostWrapper,
|
@Body body: PostWrapper
|
||||||
@Query("newsletter") newsletter: String? = null,
|
|
||||||
@Query("email_segment") emailSegment: String? = null
|
|
||||||
): Response<PostsResponse>
|
): Response<PostsResponse>
|
||||||
|
|
||||||
@PUT("ghost/api/admin/posts/{id}/")
|
@PUT("ghost/api/admin/posts/{id}/")
|
||||||
@Headers("Content-Type: application/json")
|
@Headers("Content-Type: application/json")
|
||||||
suspend fun updatePost(
|
suspend fun updatePost(
|
||||||
@Path("id") id: String,
|
@Path("id") id: String,
|
||||||
@Body body: PostWrapper,
|
@Body body: PostWrapper
|
||||||
@Query("newsletter") newsletter: String? = null,
|
|
||||||
@Query("email_segment") emailSegment: String? = null
|
|
||||||
): Response<PostsResponse>
|
): Response<PostsResponse>
|
||||||
|
|
||||||
@DELETE("ghost/api/admin/posts/{id}/")
|
@DELETE("ghost/api/admin/posts/{id}/")
|
||||||
|
|
@ -50,104 +37,15 @@ interface GhostApiService {
|
||||||
@Path("id") id: String
|
@Path("id") id: String
|
||||||
): Response<Unit>
|
): 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/")
|
@GET("ghost/api/admin/users/me/")
|
||||||
suspend fun getCurrentUser(): Response<UsersResponse>
|
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
|
@Multipart
|
||||||
@POST("ghost/api/admin/images/upload/")
|
@POST("ghost/api/admin/images/upload/")
|
||||||
suspend fun uploadImage(
|
suspend fun uploadImage(
|
||||||
@Part file: MultipartBody.Part,
|
@Part file: MultipartBody.Part,
|
||||||
@Part("purpose") purpose: RequestBody
|
@Part("purpose") purpose: RequestBody
|
||||||
): Response<ImageUploadResponse>
|
): 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(
|
data class ImageUploadResponse(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import androidx.room.migration.Migration
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.swoosh.microblog.data.model.LocalPost
|
import com.swoosh.microblog.data.model.LocalPost
|
||||||
|
|
||||||
@Database(entities = [LocalPost::class], version = 4, exportSchema = false)
|
@Database(entities = [LocalPost::class], version = 3, exportSchema = false)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
|
@ -42,25 +42,6 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
override fun migrate(db: SupportSQLiteDatabase) = addColumnsIfMissing(db)
|
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 {
|
fun getInstance(context: Context): AppDatabase {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
val instance = Room.databaseBuilder(
|
val instance = Room.databaseBuilder(
|
||||||
|
|
@ -68,7 +49,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"swoosh_database"
|
"swoosh_database"
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_1_3, MIGRATION_2_3, MIGRATION_3_4)
|
.addMigrations(MIGRATION_1_3, MIGRATION_2_3)
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
.build()
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
|
|
|
||||||
|
|
@ -11,21 +11,13 @@ class Converters {
|
||||||
fun fromPostStatus(value: PostStatus): String = value.name
|
fun fromPostStatus(value: PostStatus): String = value.name
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun toPostStatus(value: String): PostStatus = try {
|
fun toPostStatus(value: String): PostStatus = PostStatus.valueOf(value)
|
||||||
PostStatus.valueOf(value)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
PostStatus.DRAFT
|
|
||||||
}
|
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun fromQueueStatus(value: QueueStatus): String = value.name
|
fun fromQueueStatus(value: QueueStatus): String = value.name
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun toQueueStatus(value: String): QueueStatus = try {
|
fun toQueueStatus(value: String): QueueStatus = QueueStatus.valueOf(value)
|
||||||
QueueStatus.valueOf(value)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
QueueStatus.NONE
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,13 @@ interface LocalPostDao {
|
||||||
|
|
||||||
@Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC")
|
@Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC")
|
||||||
suspend fun getQueuedPosts(
|
suspend fun getQueuedPosts(
|
||||||
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED, QueueStatus.QUEUED_EMAIL_ONLY)
|
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
|
||||||
): List<LocalPost>
|
): List<LocalPost>
|
||||||
|
|
||||||
@Query("SELECT * FROM local_posts WHERE accountId = :accountId AND queueStatus IN (:statuses) ORDER BY createdAt ASC")
|
@Query("SELECT * FROM local_posts WHERE accountId = :accountId AND queueStatus IN (:statuses) ORDER BY createdAt ASC")
|
||||||
suspend fun getQueuedPostsByAccount(
|
suspend fun getQueuedPostsByAccount(
|
||||||
accountId: String,
|
accountId: String,
|
||||||
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED, QueueStatus.QUEUED_EMAIL_ONLY)
|
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
|
||||||
): List<LocalPost>
|
): List<LocalPost>
|
||||||
|
|
||||||
@Query("SELECT * FROM local_posts WHERE localId = :localId")
|
@Query("SELECT * FROM local_posts WHERE localId = :localId")
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
package com.swoosh.microblog.data.model
|
|
||||||
|
|
||||||
data class FileUploadResponse(
|
|
||||||
val files: List<UploadedFile>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UploadedFile(
|
|
||||||
val url: String,
|
|
||||||
val ref: String?
|
|
||||||
)
|
|
||||||
|
|
@ -48,8 +48,7 @@ data class GhostPost(
|
||||||
val visibility: String? = "public",
|
val visibility: String? = "public",
|
||||||
val authors: List<Author>? = null,
|
val authors: List<Author>? = null,
|
||||||
val reading_time: Int? = null,
|
val reading_time: Int? = null,
|
||||||
val tags: List<GhostTag>? = null,
|
val tags: List<GhostTag>? = null
|
||||||
val email_only: Boolean? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class GhostTag(
|
data class GhostTag(
|
||||||
|
|
@ -90,33 +89,19 @@ data class LocalPost(
|
||||||
val tags: String = "[]",
|
val tags: String = "[]",
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
val updatedAt: 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 {
|
enum class PostStatus {
|
||||||
DRAFT,
|
DRAFT,
|
||||||
PUBLISHED,
|
PUBLISHED,
|
||||||
SCHEDULED,
|
SCHEDULED
|
||||||
SENT
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class QueueStatus {
|
enum class QueueStatus {
|
||||||
NONE,
|
NONE,
|
||||||
QUEUED_PUBLISH,
|
QUEUED_PUBLISH,
|
||||||
QUEUED_SCHEDULED,
|
QUEUED_SCHEDULED,
|
||||||
QUEUED_EMAIL_ONLY,
|
|
||||||
UPLOADING,
|
UPLOADING,
|
||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
@ -135,8 +120,6 @@ data class FeedPost(
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val imageAlt: String? = null,
|
val imageAlt: String? = null,
|
||||||
val imageUrls: List<String> = emptyList(),
|
val imageUrls: List<String> = emptyList(),
|
||||||
val videoUrl: String? = null,
|
|
||||||
val audioUrl: String? = null,
|
|
||||||
val linkUrl: String?,
|
val linkUrl: String?,
|
||||||
val linkTitle: String?,
|
val linkTitle: String?,
|
||||||
val linkDescription: String?,
|
val linkDescription: String?,
|
||||||
|
|
@ -148,10 +131,7 @@ data class FeedPost(
|
||||||
val createdAt: String?,
|
val createdAt: String?,
|
||||||
val updatedAt: String?,
|
val updatedAt: String?,
|
||||||
val isLocal: Boolean = false,
|
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
|
@Stable
|
||||||
|
|
@ -168,8 +148,7 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
|
||||||
ALL("All", null),
|
ALL("All", null),
|
||||||
PUBLISHED("Published", "status:published"),
|
PUBLISHED("Published", "status:published"),
|
||||||
DRAFT("Drafts", "status:draft"),
|
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. */
|
/** Returns the matching [PostStatus] for local filtering, or null for ALL. */
|
||||||
fun toPostStatus(): PostStatus? = when (this) {
|
fun toPostStatus(): PostStatus? = when (this) {
|
||||||
|
|
@ -177,7 +156,6 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
|
||||||
PUBLISHED -> PostStatus.PUBLISHED
|
PUBLISHED -> PostStatus.PUBLISHED
|
||||||
DRAFT -> PostStatus.DRAFT
|
DRAFT -> PostStatus.DRAFT
|
||||||
SCHEDULED -> PostStatus.SCHEDULED
|
SCHEDULED -> PostStatus.SCHEDULED
|
||||||
SENT -> PostStatus.SENT
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Empty-state message shown when filter yields no results. */
|
/** Empty-state message shown when filter yields no results. */
|
||||||
|
|
@ -186,7 +164,6 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
|
||||||
PUBLISHED -> "No published posts yet"
|
PUBLISHED -> "No published posts yet"
|
||||||
DRAFT -> "No drafts yet"
|
DRAFT -> "No drafts yet"
|
||||||
SCHEDULED -> "No scheduled posts yet"
|
SCHEDULED -> "No scheduled posts yet"
|
||||||
SENT -> "No sent newsletters yet"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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?
|
|
||||||
)
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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?)
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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,7 +38,6 @@ data class OverallStats(
|
||||||
PostStatus.PUBLISHED -> publishedCount++
|
PostStatus.PUBLISHED -> publishedCount++
|
||||||
PostStatus.DRAFT -> draftCount++
|
PostStatus.DRAFT -> draftCount++
|
||||||
PostStatus.SCHEDULED -> scheduledCount++
|
PostStatus.SCHEDULED -> scheduledCount++
|
||||||
PostStatus.SENT -> publishedCount++ // sent counts as published
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,7 +47,6 @@ data class OverallStats(
|
||||||
"published" -> publishedCount++
|
"published" -> publishedCount++
|
||||||
"draft" -> draftCount++
|
"draft" -> draftCount++
|
||||||
"scheduled" -> scheduledCount++
|
"scheduled" -> scheduledCount++
|
||||||
"sent" -> publishedCount++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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?
|
|
||||||
)
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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?)
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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,7 +8,6 @@ import com.swoosh.microblog.data.api.GhostApiService
|
||||||
import com.swoosh.microblog.data.db.AppDatabase
|
import com.swoosh.microblog.data.db.AppDatabase
|
||||||
import com.swoosh.microblog.data.db.LocalPostDao
|
import com.swoosh.microblog.data.db.LocalPostDao
|
||||||
import com.swoosh.microblog.data.model.*
|
import com.swoosh.microblog.data.model.*
|
||||||
import com.swoosh.microblog.data.model.GhostNewsletter
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -69,18 +68,10 @@ class PostRepository(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createPost(
|
suspend fun createPost(post: GhostPost): Result<GhostPost> =
|
||||||
post: GhostPost,
|
|
||||||
newsletter: String? = null,
|
|
||||||
emailSegment: String? = null
|
|
||||||
): Result<GhostPost> =
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val response = getApi().createPost(
|
val response = getApi().createPost(PostWrapper(listOf(post)))
|
||||||
PostWrapper(listOf(post)),
|
|
||||||
newsletter = newsletter,
|
|
||||||
emailSegment = emailSegment
|
|
||||||
)
|
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
Result.success(response.body()!!.posts.first())
|
Result.success(response.body()!!.posts.first())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -91,20 +82,10 @@ class PostRepository(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updatePost(
|
suspend fun updatePost(id: String, post: GhostPost): Result<GhostPost> =
|
||||||
id: String,
|
|
||||||
post: GhostPost,
|
|
||||||
newsletter: String? = null,
|
|
||||||
emailSegment: String? = null
|
|
||||||
): Result<GhostPost> =
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val response = getApi().updatePost(
|
val response = getApi().updatePost(id, PostWrapper(listOf(post)))
|
||||||
id,
|
|
||||||
PostWrapper(listOf(post)),
|
|
||||||
newsletter = newsletter,
|
|
||||||
emailSegment = emailSegment
|
|
||||||
)
|
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
Result.success(response.body()!!.posts.first())
|
Result.success(response.body()!!.posts.first())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -115,35 +96,6 @@ 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> =
|
suspend fun deletePost(id: String): Result<Unit> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -161,8 +113,8 @@ class PostRepository(private val context: Context) {
|
||||||
suspend fun uploadImage(uri: Uri): Result<String> =
|
suspend fun uploadImage(uri: Uri): Result<String> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
val file = copyUriToTempFile(uri)
|
||||||
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
|
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
|
||||||
val file = copyUriToTempFile(uri, ".jpg")
|
|
||||||
val requestBody = file.asRequestBody(mimeType.toMediaType())
|
val requestBody = file.asRequestBody(mimeType.toMediaType())
|
||||||
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
|
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
|
||||||
val purpose = "image".toRequestBody("text/plain".toMediaType())
|
val purpose = "image".toRequestBody("text/plain".toMediaType())
|
||||||
|
|
@ -181,28 +133,6 @@ 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.
|
* Uploads multiple images and returns all uploaded URLs.
|
||||||
* If any upload fails, returns failure with the error.
|
* If any upload fails, returns failure with the error.
|
||||||
|
|
@ -226,41 +156,10 @@ 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)
|
val inputStream = context.contentResolver.openInputStream(uri)
|
||||||
?: throw IllegalStateException("Cannot open URI")
|
?: throw IllegalStateException("Cannot open URI")
|
||||||
val tempFile = File.createTempFile("upload_", extension, context.cacheDir)
|
val tempFile = File.createTempFile("upload_", ".jpg", context.cacheDir)
|
||||||
FileOutputStream(tempFile).use { output ->
|
FileOutputStream(tempFile).use { output ->
|
||||||
inputStream.copyTo(output)
|
inputStream.copyTo(output)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
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,8 +14,6 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
|
@ -38,22 +36,15 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.OffsetMapping
|
import androidx.compose.ui.text.input.OffsetMapping
|
||||||
import androidx.compose.ui.text.input.TransformedText
|
import androidx.compose.ui.text.input.TransformedText
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.swoosh.microblog.data.AccountManager
|
|
||||||
import com.swoosh.microblog.data.HashtagParser
|
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.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.data.model.PostStats
|
||||||
import com.swoosh.microblog.ui.animation.SwooshMotion
|
import com.swoosh.microblog.ui.animation.SwooshMotion
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -114,31 +105,8 @@ 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 {
|
val canSubmit by remember {
|
||||||
derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty() || state.fileUri != null || state.videoUri != null || state.audioUri != null) }
|
derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -175,23 +143,15 @@ fun ComposerScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val isNewsletterPublish = state.sendAsNewsletter && state.selectedNewsletter != null
|
|
||||||
FilledIconButton(
|
FilledIconButton(
|
||||||
onClick = viewModel::publish,
|
onClick = viewModel::publish,
|
||||||
enabled = canSubmit,
|
enabled = canSubmit,
|
||||||
colors = IconButtonDefaults.filledIconButtonColors(
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
containerColor = if (isNewsletterPublish)
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
MaterialTheme.colorScheme.tertiaryContainer
|
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
else MaterialTheme.colorScheme.primary,
|
|
||||||
contentColor = if (isNewsletterPublish)
|
|
||||||
MaterialTheme.colorScheme.onTertiaryContainer
|
|
||||||
else MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(Icons.Default.Send, "Publish")
|
||||||
if (isNewsletterPublish) Icons.Default.Email else Icons.Default.Send,
|
|
||||||
if (isNewsletterPublish) "Publish & Send Email" else "Publish"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,24 +171,13 @@ fun ComposerScreen(
|
||||||
expanded = showSendMenu,
|
expanded = showSendMenu,
|
||||||
onDismissRequest = { showSendMenu = false }
|
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(
|
DropdownMenuItem(
|
||||||
text = { Text(publishLabel) },
|
text = { Text(if (state.isEditing) "Update & Publish" else "Publish Now") },
|
||||||
onClick = {
|
onClick = {
|
||||||
showSendMenu = false
|
showSendMenu = false
|
||||||
viewModel.publish()
|
viewModel.publish()
|
||||||
},
|
},
|
||||||
leadingIcon = {
|
leadingIcon = { Icon(Icons.Default.Send, null) },
|
||||||
Icon(
|
|
||||||
if (state.sendAsNewsletter && state.selectedNewsletter != null)
|
|
||||||
Icons.Default.Email else Icons.Default.Send,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = canSubmit
|
enabled = canSubmit
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
|
|
@ -249,31 +198,6 @@ fun ComposerScreen(
|
||||||
leadingIcon = { Icon(Icons.Default.Schedule, null) },
|
leadingIcon = { Icon(Icons.Default.Schedule, null) },
|
||||||
enabled = canSubmit
|
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -285,39 +209,6 @@ fun ComposerScreen(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.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
|
// Hashtag visual transformation for edit mode text field
|
||||||
val hashtagColor = MaterialTheme.colorScheme.primary
|
val hashtagColor = MaterialTheme.colorScheme.primary
|
||||||
val hashtagTransformation = remember(hashtagColor) {
|
val hashtagTransformation = remember(hashtagColor) {
|
||||||
|
|
@ -432,17 +323,48 @@ fun ComposerScreen(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tags section: input + suggestions + chips (only when tags enabled)
|
// Extracted tags preview chips
|
||||||
if (viewModel.isTagsEnabled()) {
|
AnimatedVisibility(
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
visible = state.extractedTags.isNotEmpty(),
|
||||||
TagsSection(
|
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
|
||||||
tagInput = state.tagInput,
|
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
|
||||||
onTagInputChange = viewModel::updateTagInput,
|
) {
|
||||||
tagSuggestions = state.tagSuggestions,
|
Column {
|
||||||
extractedTags = state.extractedTags,
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
onAddTag = viewModel::addTag,
|
Row(
|
||||||
onRemoveTag = viewModel::removeTag
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
@ -454,21 +376,9 @@ fun ComposerScreen(
|
||||||
OutlinedIconButton(onClick = { multiImagePickerLauncher.launch("image/*") }) {
|
OutlinedIconButton(onClick = { multiImagePickerLauncher.launch("image/*") }) {
|
||||||
Icon(Icons.Default.Image, "Attach images")
|
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 }) {
|
OutlinedIconButton(onClick = { showLinkDialog = true }) {
|
||||||
Icon(Icons.Default.Link, "Add link")
|
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
|
// Image grid preview (multi-image) — 120dp thumbnails
|
||||||
|
|
@ -518,57 +428,6 @@ 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
|
// Link preview
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = state.isLoadingLink,
|
visible = state.isLoadingLink,
|
||||||
|
|
@ -826,384 +685,6 @@ 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1397,276 +878,3 @@ class HashtagVisualTransformation(private val hashtagColor: Color) : VisualTrans
|
||||||
return TransformedText(annotated, OffsetMapping.Identity)
|
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,14 +6,11 @@ import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.swoosh.microblog.data.HashtagParser
|
import com.swoosh.microblog.data.HashtagParser
|
||||||
import com.swoosh.microblog.data.MobiledocBuilder
|
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.PreviewHtmlBuilder
|
||||||
import com.swoosh.microblog.data.db.Converters
|
import com.swoosh.microblog.data.db.Converters
|
||||||
import com.swoosh.microblog.data.model.*
|
import com.swoosh.microblog.data.model.*
|
||||||
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
import com.swoosh.microblog.data.repository.TagRepository
|
|
||||||
import com.swoosh.microblog.worker.PostUploadWorker
|
import com.swoosh.microblog.worker.PostUploadWorker
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
|
|
@ -28,9 +25,6 @@ import kotlinx.coroutines.launch
|
||||||
class ComposerViewModel(application: Application) : AndroidViewModel(application) {
|
class ComposerViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val repository = PostRepository(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 appContext = application
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ComposerUiState())
|
private val _uiState = MutableStateFlow(ComposerUiState())
|
||||||
|
|
@ -42,157 +36,6 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
|
|
||||||
private var previewDebounceJob: Job? = null
|
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) {
|
fun loadForEdit(post: FeedPost) {
|
||||||
editingLocalId = post.localId
|
editingLocalId = post.localId
|
||||||
editingGhostId = post.ghostId
|
editingGhostId = post.ghostId
|
||||||
|
|
@ -211,8 +54,6 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
text = post.textContent,
|
text = post.textContent,
|
||||||
imageUris = imageUris,
|
imageUris = imageUris,
|
||||||
imageAlt = post.imageAlt ?: "",
|
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(
|
linkPreview = if (post.linkUrl != null) LinkPreview(
|
||||||
url = post.linkUrl,
|
url = post.linkUrl,
|
||||||
title = post.linkTitle,
|
title = post.linkTitle,
|
||||||
|
|
@ -272,73 +113,6 @@ 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) {
|
fun fetchLinkPreview(url: String) {
|
||||||
if (url.isBlank()) return
|
if (url.isBlank()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -406,15 +180,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
_uiState.update { it.copy(featured = !it.featured) }
|
_uiState.update { it.copy(featured = !it.featured) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun publish() {
|
fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_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)
|
fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE)
|
||||||
|
|
||||||
|
|
@ -425,16 +191,14 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
|
|
||||||
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
|
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
if (state.text.isBlank() && state.imageUris.isEmpty() && state.fileUri == null && state.videoUri == null && state.audioUri == null) return
|
if (state.text.isBlank() && state.imageUris.isEmpty()) return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
||||||
|
|
||||||
val title = state.text.take(60)
|
val title = state.text.take(60)
|
||||||
// Merge hashtag-parsed tags with manually-added tags (deduplicated)
|
val extractedTags = HashtagParser.parse(state.text)
|
||||||
val hashtagTags = HashtagParser.parse(state.text)
|
val tagsJson = Gson().toJson(extractedTags)
|
||||||
val allTags = (state.extractedTags + hashtagTags).distinctBy { it.lowercase() }
|
|
||||||
val tagsJson = Gson().toJson(allTags)
|
|
||||||
|
|
||||||
val altText = state.imageAlt.ifBlank { null }
|
val altText = state.imageAlt.ifBlank { null }
|
||||||
|
|
||||||
|
|
@ -450,18 +214,13 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
imageUri = state.imageUris.firstOrNull()?.toString(),
|
imageUri = state.imageUris.firstOrNull()?.toString(),
|
||||||
imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }),
|
imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }),
|
||||||
imageAlt = altText,
|
imageAlt = altText,
|
||||||
videoUri = state.videoUri?.toString(),
|
|
||||||
audioUri = state.audioUri?.toString(),
|
|
||||||
linkUrl = state.linkPreview?.url,
|
linkUrl = state.linkPreview?.url,
|
||||||
linkTitle = state.linkPreview?.title,
|
linkTitle = state.linkPreview?.title,
|
||||||
linkDescription = state.linkPreview?.description,
|
linkDescription = state.linkPreview?.description,
|
||||||
linkImageUrl = state.linkPreview?.imageUrl,
|
linkImageUrl = state.linkPreview?.imageUrl,
|
||||||
scheduledAt = state.scheduledAt,
|
scheduledAt = state.scheduledAt,
|
||||||
tags = tagsJson,
|
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)
|
repository.saveLocalPost(localPost)
|
||||||
|
|
||||||
|
|
@ -486,66 +245,14 @@ 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 featureImage = uploadedImageUrls.firstOrNull()
|
||||||
|
|
||||||
val mobiledoc = MobiledocBuilder.build(
|
val mobiledoc = MobiledocBuilder.build(
|
||||||
text = state.text,
|
state.text, uploadedImageUrls,
|
||||||
imageUrls = uploadedImageUrls,
|
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
|
||||||
linkUrl = state.linkPreview?.url,
|
altText
|
||||||
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 = allTags.map { GhostTag(name = it) }
|
val ghostTags = extractedTags.map { GhostTag(name = it) }
|
||||||
|
|
||||||
val ghostPost = GhostPost(
|
val ghostPost = GhostPost(
|
||||||
title = title,
|
title = title,
|
||||||
|
|
@ -559,17 +266,11 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
tags = ghostTags.ifEmpty { null }
|
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 result = if (editingGhostId != null) {
|
||||||
val updatePost = ghostPost.copy(updated_at = editingUpdatedAt)
|
val updatePost = ghostPost.copy(updated_at = editingUpdatedAt)
|
||||||
repository.updatePost(editingGhostId!!, updatePost, newsletter = newsletterSlug, emailSegment = emailSeg)
|
repository.updatePost(editingGhostId!!, updatePost)
|
||||||
} else {
|
} else {
|
||||||
repository.createPost(ghostPost, newsletter = newsletterSlug, emailSegment = emailSeg)
|
repository.createPost(ghostPost)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.fold(
|
result.fold(
|
||||||
|
|
@ -590,21 +291,13 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
uploadedImageUrl = featureImage,
|
uploadedImageUrl = featureImage,
|
||||||
uploadedImageUrls = Converters.stringListToJson(uploadedImageUrls),
|
uploadedImageUrls = Converters.stringListToJson(uploadedImageUrls),
|
||||||
imageAlt = altText,
|
imageAlt = altText,
|
||||||
videoUri = state.videoUri?.toString(),
|
|
||||||
uploadedVideoUrl = videoUrl,
|
|
||||||
audioUri = state.audioUri?.toString(),
|
|
||||||
uploadedAudioUrl = audioUrl,
|
|
||||||
linkUrl = state.linkPreview?.url,
|
linkUrl = state.linkPreview?.url,
|
||||||
linkTitle = state.linkPreview?.title,
|
linkTitle = state.linkPreview?.title,
|
||||||
linkDescription = state.linkPreview?.description,
|
linkDescription = state.linkPreview?.description,
|
||||||
linkImageUrl = state.linkPreview?.imageUrl,
|
linkImageUrl = state.linkPreview?.imageUrl,
|
||||||
scheduledAt = state.scheduledAt,
|
scheduledAt = state.scheduledAt,
|
||||||
tags = tagsJson,
|
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)
|
repository.saveLocalPost(localPost)
|
||||||
PostUploadWorker.enqueue(appContext)
|
PostUploadWorker.enqueue(appContext)
|
||||||
|
|
@ -631,41 +324,17 @@ data class ComposerUiState(
|
||||||
val text: String = "",
|
val text: String = "",
|
||||||
val imageUris: List<Uri> = emptyList(),
|
val imageUris: List<Uri> = emptyList(),
|
||||||
val imageAlt: String = "",
|
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 linkPreview: LinkPreview? = null,
|
||||||
val isLoadingLink: Boolean = false,
|
val isLoadingLink: Boolean = false,
|
||||||
val scheduledAt: String? = null,
|
val scheduledAt: String? = null,
|
||||||
val featured: Boolean = false,
|
val featured: Boolean = false,
|
||||||
val extractedTags: List<String> = emptyList(),
|
val extractedTags: List<String> = emptyList(),
|
||||||
val availableTags: List<GhostTagFull> = emptyList(),
|
|
||||||
val tagSuggestions: List<GhostTagFull> = emptyList(),
|
|
||||||
val tagInput: String = "",
|
|
||||||
val isSubmitting: Boolean = false,
|
val isSubmitting: Boolean = false,
|
||||||
val isSuccess: Boolean = false,
|
val isSuccess: Boolean = false,
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val isPreviewMode: Boolean = false,
|
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.
|
* Backwards compatibility: returns the first image URI or null.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import androidx.compose.material.icons.automirrored.filled.Article
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
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.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Image
|
import androidx.compose.material.icons.filled.Image
|
||||||
|
|
@ -54,10 +53,7 @@ import com.swoosh.microblog.data.model.LinkPreview
|
||||||
import com.swoosh.microblog.data.model.PostStats
|
import com.swoosh.microblog.data.model.PostStats
|
||||||
import com.swoosh.microblog.data.model.QueueStatus
|
import com.swoosh.microblog.data.model.QueueStatus
|
||||||
import com.swoosh.microblog.ui.animation.SwooshMotion
|
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.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.FullScreenGallery
|
||||||
import com.swoosh.microblog.ui.feed.StatusBadge
|
import com.swoosh.microblog.ui.feed.StatusBadge
|
||||||
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
||||||
|
|
@ -98,7 +94,7 @@ fun DetailScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
// D1: Content reveal sequence
|
// D1: Content reveal sequence
|
||||||
val revealCount = 8 // status, text, tags, gallery, video, audio, link, stats
|
val revealCount = 6 // status, text, tags, gallery, link, stats
|
||||||
val sectionVisible = remember { List(revealCount) { mutableStateOf(false) } }
|
val sectionVisible = remember { List(revealCount) { mutableStateOf(false) } }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
sectionVisible.forEachIndexed { index, state ->
|
sectionVisible.forEachIndexed { index, state ->
|
||||||
|
|
@ -304,31 +300,9 @@ fun DetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section 4 — Video player
|
// Section 4 — Link preview
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = sectionVisible[4].value && post.videoUrl != null,
|
visible = sectionVisible[4].value && post.linkUrl != 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())
|
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
|
|
@ -374,25 +348,9 @@ fun DetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// File attachment
|
// Section 5 — PostStatsSection
|
||||||
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(
|
AnimatedVisibility(
|
||||||
visible = sectionVisible[7].value,
|
visible = sectionVisible[5].value,
|
||||||
enter = slideInVertically(initialOffsetY = { it / 4 }, animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick())
|
enter = slideInVertically(initialOffsetY = { it / 4 }, animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick())
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
|
|
@ -400,43 +358,6 @@ fun DetailScreen(
|
||||||
PostStatsSection(post)
|
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,7 +33,6 @@ import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
|
@ -75,26 +74,20 @@ import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.DpOffset
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import android.content.Intent
|
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.swoosh.microblog.data.CredentialsManager
|
import com.swoosh.microblog.data.CredentialsManager
|
||||||
import com.swoosh.microblog.data.toDisplayUrl
|
|
||||||
import com.swoosh.microblog.data.ShareUtils
|
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.FeedPost
|
||||||
import com.swoosh.microblog.data.model.GhostAccount
|
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.PostFilter
|
||||||
import com.swoosh.microblog.data.model.PostStats
|
import com.swoosh.microblog.data.model.PostStats
|
||||||
import com.swoosh.microblog.data.model.QueueStatus
|
import com.swoosh.microblog.data.model.QueueStatus
|
||||||
import com.swoosh.microblog.data.model.SortOrder
|
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.ConfirmationDialog
|
||||||
import com.swoosh.microblog.ui.components.VideoPlayer
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -116,16 +109,10 @@ fun FeedScreen(
|
||||||
val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle()
|
val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle()
|
||||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||||
val activeAccount by viewModel.activeAccount.collectAsStateWithLifecycle()
|
val activeAccount by viewModel.activeAccount.collectAsStateWithLifecycle()
|
||||||
val popularTags by viewModel.popularTags.collectAsStateWithLifecycle()
|
|
||||||
val tagsEnabled by viewModel.tagsEnabled.collectAsStateWithLifecycle()
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val baseUrl = remember { CredentialsManager(context).ghostUrl }
|
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
|
// Track which post is pending delete confirmation
|
||||||
var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) }
|
var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) }
|
||||||
|
|
@ -214,19 +201,8 @@ fun FeedScreen(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.clickable { showAccountSwitcher = true }
|
modifier = Modifier.clickable { showAccountSwitcher = true }
|
||||||
) {
|
) {
|
||||||
// Site icon or account avatar
|
// Account color indicator
|
||||||
val siteIconUrl = siteData?.icon ?: siteData?.logo
|
if (activeAccount != null) {
|
||||||
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(
|
AccountAvatar(
|
||||||
account = activeAccount!!,
|
account = activeAccount!!,
|
||||||
size = 28
|
size = 28
|
||||||
|
|
@ -235,19 +211,18 @@ fun FeedScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
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(
|
||||||
text = displayName,
|
text = activeAccount?.name ?: "Swoosh",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
if (activeAccount != null) {
|
if (activeAccount != null) {
|
||||||
Text(
|
Text(
|
||||||
text = activeAccount!!.blogUrl.toDisplayUrl(),
|
text = activeAccount!!.blogUrl
|
||||||
|
.removePrefix("https://")
|
||||||
|
.removePrefix("http://")
|
||||||
|
.removeSuffix("/"),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
@ -263,7 +238,7 @@ fun FeedScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accounts.size > 1) {
|
if (accounts.size > 1 || accounts.isNotEmpty()) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.KeyboardArrowDown,
|
Icons.Default.KeyboardArrowDown,
|
||||||
contentDescription = "Switch account",
|
contentDescription = "Switch account",
|
||||||
|
|
@ -330,17 +305,20 @@ fun FeedScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag filter chips
|
// Active tag filter bar
|
||||||
AnimatedVisibility(
|
if (state.activeTagFilter != null) {
|
||||||
visible = !isSearchActive && tagsEnabled && popularTags.isNotEmpty(),
|
FilterChip(
|
||||||
enter = fadeIn(SwooshMotion.quick()) + expandVertically(),
|
onClick = { viewModel.clearTagFilter() },
|
||||||
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically()
|
label = { Text("#${state.activeTagFilter}") },
|
||||||
) {
|
selected = true,
|
||||||
TagFilterChipsBar(
|
leadingIcon = {
|
||||||
tags = popularTags,
|
Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
activeTagFilter = state.activeTagFilter,
|
},
|
||||||
onTagSelected = { viewModel.filterByTag(it) },
|
trailingIcon = {
|
||||||
onClearFilter = { viewModel.clearTagFilter() }
|
Icon(Icons.Default.Close, contentDescription = "Clear filter", modifier = Modifier.size(16.dp))
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -589,7 +567,7 @@ fun FeedScreen(
|
||||||
onEdit = { onEditPost(post) },
|
onEdit = { onEditPost(post) },
|
||||||
onDelete = { postPendingDelete = post },
|
onDelete = { postPendingDelete = post },
|
||||||
onTogglePin = { viewModel.toggleFeatured(post) },
|
onTogglePin = { viewModel.toggleFeatured(post) },
|
||||||
onTagClick = { tag -> if (tagsEnabled) viewModel.filterByTag(tag) },
|
onTagClick = { tag -> viewModel.filterByTag(tag) },
|
||||||
snackbarHostState = snackbarHostState
|
snackbarHostState = snackbarHostState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -615,7 +593,7 @@ fun FeedScreen(
|
||||||
onEdit = { onEditPost(post) },
|
onEdit = { onEditPost(post) },
|
||||||
onDelete = { postPendingDelete = post },
|
onDelete = { postPendingDelete = post },
|
||||||
onTogglePin = { viewModel.toggleFeatured(post) },
|
onTogglePin = { viewModel.toggleFeatured(post) },
|
||||||
onTagClick = { tag -> if (tagsEnabled) viewModel.filterByTag(tag) },
|
onTagClick = { tag -> viewModel.filterByTag(tag) },
|
||||||
snackbarHostState = snackbarHostState
|
snackbarHostState = snackbarHostState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -768,12 +746,6 @@ fun FilterChipsBar(
|
||||||
activeFilter: PostFilter,
|
activeFilter: PostFilter,
|
||||||
onFilterSelected: (PostFilter) -> Unit
|
onFilterSelected: (PostFilter) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
val newsletterEnabled = remember {
|
|
||||||
com.swoosh.microblog.data.NewsletterPreferences(context).isNewsletterEnabled()
|
|
||||||
}
|
|
||||||
val sentColor = Color(0xFF6A1B9A) // magenta
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -782,29 +754,19 @@ fun FilterChipsBar(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
PostFilter.values().forEach { filter ->
|
PostFilter.values().forEach { filter ->
|
||||||
// Only show SENT chip when newsletter is enabled
|
|
||||||
if (filter == PostFilter.SENT && !newsletterEnabled) return@forEach
|
|
||||||
|
|
||||||
val selected = filter == activeFilter
|
val selected = filter == activeFilter
|
||||||
val containerColor by animateColorAsState(
|
val containerColor by animateColorAsState(
|
||||||
targetValue = when {
|
targetValue = if (selected)
|
||||||
selected && filter == PostFilter.SENT -> sentColor.copy(alpha = 0.2f)
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
selected -> MaterialTheme.colorScheme.primaryContainer
|
else
|
||||||
else -> MaterialTheme.colorScheme.surface
|
MaterialTheme.colorScheme.surface,
|
||||||
},
|
|
||||||
animationSpec = SwooshMotion.quick(),
|
animationSpec = SwooshMotion.quick(),
|
||||||
label = "chipColor"
|
label = "chipColor"
|
||||||
)
|
)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selected,
|
selected = selected,
|
||||||
onClick = { onFilterSelected(filter) },
|
onClick = { onFilterSelected(filter) },
|
||||||
label = {
|
label = { Text(filter.displayName) },
|
||||||
Text(
|
|
||||||
filter.displayName,
|
|
||||||
color = if (selected && filter == PostFilter.SENT) sentColor
|
|
||||||
else Color.Unspecified
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = FilterChipDefaults.filterChipColors(
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
selectedContainerColor = containerColor
|
selectedContainerColor = containerColor
|
||||||
)
|
)
|
||||||
|
|
@ -813,58 +775,6 @@ 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
|
@Composable
|
||||||
fun SortButton(
|
fun SortButton(
|
||||||
currentSort: SortOrder,
|
currentSort: SortOrder,
|
||||||
|
|
@ -1177,7 +1087,10 @@ fun AccountListItem(
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(
|
Text(
|
||||||
account.blogUrl.toDisplayUrl(),
|
account.blogUrl
|
||||||
|
.removePrefix("https://")
|
||||||
|
.removePrefix("http://")
|
||||||
|
.removeSuffix("/"),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
@ -1439,6 +1352,11 @@ fun PostCardContent(
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
var showContextMenu by remember { mutableStateOf(false) }
|
var showContextMenu by remember { mutableStateOf(false) }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
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 isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE
|
||||||
val hasShareableUrl = !post.slug.isNullOrBlank() || !post.url.isNullOrBlank()
|
val hasShareableUrl = !post.slug.isNullOrBlank() || !post.url.isNullOrBlank()
|
||||||
|
|
@ -1497,8 +1415,7 @@ fun PostCardContent(
|
||||||
// Content -- the star of the show
|
// Content -- the star of the show
|
||||||
if (highlightQuery != null && highlightQuery.isNotBlank()) {
|
if (highlightQuery != null && highlightQuery.isNotBlank()) {
|
||||||
HighlightedText(
|
HighlightedText(
|
||||||
text = if (expanded || post.textContent.length <= 280) post.textContent
|
text = displayText,
|
||||||
else post.textContent.take(280) + "...",
|
|
||||||
query = highlightQuery,
|
query = highlightQuery,
|
||||||
maxLines = if (expanded) Int.MAX_VALUE else 8
|
maxLines = if (expanded) Int.MAX_VALUE else 8
|
||||||
)
|
)
|
||||||
|
|
@ -1566,18 +1483,6 @@ 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
|
// Link preview
|
||||||
if (post.linkUrl != null && post.linkTitle != null) {
|
if (post.linkUrl != null && post.linkTitle != null) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
@ -1614,28 +1519,23 @@ fun PostCardContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// File attachment
|
// Hashtag tags (bold colored text, not chips)
|
||||||
if (post.fileUrl != null) {
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
FileAttachmentCard(
|
|
||||||
fileUrl = post.fileUrl,
|
|
||||||
fileName = post.fileName ?: "File"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags display
|
|
||||||
if (post.tags.isNotEmpty()) {
|
if (post.tags.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
Text(
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
text = post.tags.joinToString(" \u00B7 ") { "#$it" },
|
FlowRow(
|
||||||
style = MaterialTheme.typography.labelSmall,
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
color = MaterialTheme.colorScheme.primary,
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
maxLines = 1,
|
) {
|
||||||
overflow = TextOverflow.Ellipsis,
|
post.tags.forEach { tag ->
|
||||||
modifier = Modifier.clickable {
|
Text(
|
||||||
post.tags.firstOrNull()?.let { onTagClick(it) }
|
text = "#$tag",
|
||||||
|
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.clickable { onTagClick(tag) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue status
|
// Queue status
|
||||||
|
|
@ -1643,7 +1543,6 @@ fun PostCardContent(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
val queueLabel = when (post.queueStatus) {
|
val queueLabel = when (post.queueStatus) {
|
||||||
QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload"
|
QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload"
|
||||||
QueueStatus.QUEUED_EMAIL_ONLY -> "Pending email send"
|
|
||||||
QueueStatus.UPLOADING -> "Uploading..."
|
QueueStatus.UPLOADING -> "Uploading..."
|
||||||
QueueStatus.FAILED -> "Upload failed"
|
QueueStatus.FAILED -> "Upload failed"
|
||||||
else -> ""
|
else -> ""
|
||||||
|
|
@ -1654,7 +1553,7 @@ fun PostCardContent(
|
||||||
label = queueLabel,
|
label = queueLabel,
|
||||||
isUploading = isUploading
|
isUploading = isUploading
|
||||||
)
|
)
|
||||||
if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED || post.queueStatus == QueueStatus.QUEUED_EMAIL_ONLY) {
|
if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED) {
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
TextButton(onClick = onCancelQueue) {
|
TextButton(onClick = onCancelQueue) {
|
||||||
Text("Cancel", style = MaterialTheme.typography.labelSmall)
|
Text("Cancel", style = MaterialTheme.typography.labelSmall)
|
||||||
|
|
@ -1669,15 +1568,12 @@ fun PostCardContent(
|
||||||
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
|
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
|
||||||
PostStats.fromFeedPost(post)
|
PostStats.fromFeedPost(post)
|
||||||
}
|
}
|
||||||
val isSent = post.status == "sent" || post.emailOnly
|
|
||||||
val statusLabel = when {
|
val statusLabel = when {
|
||||||
post.queueStatus != QueueStatus.NONE -> "Pending"
|
post.queueStatus != QueueStatus.NONE -> "Pending"
|
||||||
isSent -> "Sent"
|
|
||||||
else -> post.status.replaceFirstChar { it.uppercase() }
|
else -> post.status.replaceFirstChar { it.uppercase() }
|
||||||
}
|
}
|
||||||
val statusColor = when {
|
val statusColor = when {
|
||||||
post.queueStatus != QueueStatus.NONE -> Color(0xFFE65100)
|
post.queueStatus != QueueStatus.NONE -> Color(0xFFE65100)
|
||||||
isSent -> Color(0xFF6A1B9A)
|
|
||||||
post.status == "published" -> Color(0xFF2E7D32)
|
post.status == "published" -> Color(0xFF2E7D32)
|
||||||
post.status == "scheduled" -> Color(0xFF1565C0)
|
post.status == "scheduled" -> Color(0xFF1565C0)
|
||||||
else -> Color(0xFF7B1FA2)
|
else -> Color(0xFF7B1FA2)
|
||||||
|
|
@ -1686,28 +1582,19 @@ fun PostCardContent(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
if (isSent) {
|
Box(
|
||||||
Icon(
|
modifier = Modifier
|
||||||
Icons.Default.Email,
|
.size(8.dp)
|
||||||
contentDescription = "Sent",
|
.clip(CircleShape)
|
||||||
modifier = Modifier.size(12.dp),
|
.background(statusColor)
|
||||||
tint = statusColor
|
)
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(statusColor)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = statusLabel,
|
text = statusLabel,
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
|
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
|
||||||
color = statusColor
|
color = statusColor
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "\u00B7",
|
text = "·",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
@ -1748,35 +1635,8 @@ fun PostCardContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Share / Copy content action
|
// Share action (copies link to clipboard)
|
||||||
if (isSent) {
|
if (isPublished && hasShareableUrl) {
|
||||||
// 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(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
|
|
@ -2229,10 +2089,8 @@ fun buildHighlightedString(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StatusBadge(post: FeedPost) {
|
fun StatusBadge(post: FeedPost) {
|
||||||
val isSent = post.status == "sent" || post.emailOnly
|
|
||||||
val (label, containerColor, labelColor) = when {
|
val (label, containerColor, labelColor) = when {
|
||||||
post.queueStatus != QueueStatus.NONE -> Triple("Pending", Color(0xFFFFF3E0), Color(0xFFE65100))
|
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 == "published" -> Triple("Published", Color(0xFFE8F5E9), Color(0xFF2E7D32))
|
||||||
post.status == "scheduled" -> Triple("Scheduled", Color(0xFFE3F2FD), Color(0xFF1565C0))
|
post.status == "scheduled" -> Triple("Scheduled", Color(0xFFE3F2FD), Color(0xFF1565C0))
|
||||||
else -> Triple("Draft", Color(0xFFF3E5F5), Color(0xFF7B1FA2))
|
else -> Triple("Draft", Color(0xFFF3E5F5), Color(0xFF7B1FA2))
|
||||||
|
|
@ -2250,57 +2108,3 @@ fun StatusBadge(post: FeedPost) {
|
||||||
border = null
|
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,12 +9,10 @@ import com.swoosh.microblog.data.AccountManager
|
||||||
import com.swoosh.microblog.data.CredentialsManager
|
import com.swoosh.microblog.data.CredentialsManager
|
||||||
import com.swoosh.microblog.data.FeedPreferences
|
import com.swoosh.microblog.data.FeedPreferences
|
||||||
import com.swoosh.microblog.data.HashtagParser
|
import com.swoosh.microblog.data.HashtagParser
|
||||||
import com.swoosh.microblog.data.TagsPreferences
|
|
||||||
import com.swoosh.microblog.data.api.ApiClient
|
import com.swoosh.microblog.data.api.ApiClient
|
||||||
import com.swoosh.microblog.data.db.Converters
|
import com.swoosh.microblog.data.db.Converters
|
||||||
import com.swoosh.microblog.data.model.*
|
import com.swoosh.microblog.data.model.*
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
import com.swoosh.microblog.data.repository.TagRepository
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
|
@ -38,12 +36,9 @@ data class SnackbarEvent(
|
||||||
|
|
||||||
class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val gson = Gson()
|
|
||||||
private val accountManager = AccountManager(application)
|
private val accountManager = AccountManager(application)
|
||||||
private var repository = PostRepository(application)
|
private var repository = PostRepository(application)
|
||||||
private var tagRepository = TagRepository(application)
|
|
||||||
private val feedPreferences = FeedPreferences(application)
|
private val feedPreferences = FeedPreferences(application)
|
||||||
private val tagsPreferences = TagsPreferences(application)
|
|
||||||
private val searchHistoryManager = SearchHistoryManager(application)
|
private val searchHistoryManager = SearchHistoryManager(application)
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(FeedUiState())
|
private val _uiState = MutableStateFlow(FeedUiState())
|
||||||
|
|
@ -76,12 +71,6 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val _recentSearches = MutableStateFlow<List<String>>(emptyList())
|
private val _recentSearches = MutableStateFlow<List<String>>(emptyList())
|
||||||
val recentSearches: StateFlow<List<String>> = _recentSearches.asStateFlow()
|
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())
|
private val _accounts = MutableStateFlow<List<GhostAccount>>(emptyList())
|
||||||
val accounts: StateFlow<List<GhostAccount>> = _accounts.asStateFlow()
|
val accounts: StateFlow<List<GhostAccount>> = _accounts.asStateFlow()
|
||||||
|
|
||||||
|
|
@ -248,9 +237,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
accountManager.setActiveAccount(accountId)
|
accountManager.setActiveAccount(accountId)
|
||||||
ApiClient.reset()
|
ApiClient.reset()
|
||||||
|
|
||||||
// Re-create repositories to pick up new account
|
// Re-create repository to pick up new account
|
||||||
repository = PostRepository(getApplication())
|
repository = PostRepository(getApplication())
|
||||||
tagRepository = TagRepository(getApplication())
|
|
||||||
|
|
||||||
refreshAccountsList()
|
refreshAccountsList()
|
||||||
|
|
||||||
|
|
@ -308,25 +296,6 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
val sort = _sortOrder.value
|
val sort = _sortOrder.value
|
||||||
val tagFilter = _uiState.value.activeTagFilter
|
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(
|
repository.fetchPosts(page = 1, filter = filter, sortOrder = sort, tagFilter = tagFilter).fold(
|
||||||
onSuccess = { response ->
|
onSuccess = { response ->
|
||||||
remotePosts = response.posts.map { it.toFeedPost() }
|
remotePosts = response.posts.map { it.toFeedPost() }
|
||||||
|
|
@ -343,15 +312,6 @@ 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) {
|
fun filterByTag(tag: String) {
|
||||||
_uiState.update { it.copy(activeTagFilter = tag) }
|
_uiState.update { it.copy(activeTagFilter = tag) }
|
||||||
refresh()
|
refresh()
|
||||||
|
|
@ -545,74 +505,18 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
_uiState.update { it.copy(posts = sorted) }
|
_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 {
|
private fun GhostPost.toFeedPost(): FeedPost {
|
||||||
val mobiledocCards = parseMobiledocCards(mobiledoc)
|
val imageUrls = extractImageUrlsFromMobiledoc(mobiledoc)
|
||||||
// Use feature_image as primary, then add mobiledoc images, then HTML images (avoiding duplicates)
|
// Use feature_image as primary, then add mobiledoc images (avoiding duplicates)
|
||||||
val allImages = mutableListOf<String>()
|
val allImages = mutableListOf<String>()
|
||||||
if (feature_image != null) {
|
if (feature_image != null) {
|
||||||
allImages.add(feature_image)
|
allImages.add(feature_image)
|
||||||
}
|
}
|
||||||
for (url in mobiledocCards.imageUrls) {
|
for (url in imageUrls) {
|
||||||
if (url !in allImages) {
|
if (url !in allImages) {
|
||||||
allImages.add(url)
|
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(
|
return FeedPost(
|
||||||
ghostId = id,
|
ghostId = id,
|
||||||
slug = slug,
|
slug = slug,
|
||||||
|
|
@ -623,8 +527,6 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
imageUrl = allImages.firstOrNull(),
|
imageUrl = allImages.firstOrNull(),
|
||||||
imageAlt = feature_image_alt,
|
imageAlt = feature_image_alt,
|
||||||
imageUrls = allImages,
|
imageUrls = allImages,
|
||||||
videoUrl = mobiledocCards.videoUrl,
|
|
||||||
audioUrl = mobiledocCards.audioUrl,
|
|
||||||
linkUrl = null,
|
linkUrl = null,
|
||||||
linkTitle = null,
|
linkTitle = null,
|
||||||
linkDescription = null,
|
linkDescription = null,
|
||||||
|
|
@ -635,16 +537,34 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
publishedAt = published_at,
|
publishedAt = published_at,
|
||||||
createdAt = created_at,
|
createdAt = created_at,
|
||||||
updatedAt = updated_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 {
|
private fun LocalPost.toFeedPost(): FeedPost {
|
||||||
val tagNames: List<String> = try {
|
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) {
|
} catch (e: Exception) {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
@ -666,8 +586,6 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
imageUrl = allImageUrls.firstOrNull(),
|
imageUrl = allImageUrls.firstOrNull(),
|
||||||
imageAlt = imageAlt,
|
imageAlt = imageAlt,
|
||||||
imageUrls = allImageUrls,
|
imageUrls = allImageUrls,
|
||||||
videoUrl = uploadedVideoUrl ?: videoUri,
|
|
||||||
audioUrl = uploadedAudioUrl ?: audioUri,
|
|
||||||
linkUrl = linkUrl,
|
linkUrl = linkUrl,
|
||||||
linkTitle = linkTitle,
|
linkTitle = linkTitle,
|
||||||
linkDescription = linkDescription,
|
linkDescription = linkDescription,
|
||||||
|
|
@ -679,10 +597,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
createdAt = null,
|
createdAt = null,
|
||||||
updatedAt = null,
|
updatedAt = null,
|
||||||
isLocal = true,
|
isLocal = true,
|
||||||
queueStatus = queueStatus,
|
queueStatus = queueStatus
|
||||||
fileUrl = uploadedFileUrl ?: fileUri,
|
|
||||||
fileName = fileName,
|
|
||||||
emailOnly = emailOnly
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,437 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,385 +0,0 @@
|
||||||
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) {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
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,7 +11,6 @@ import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.BarChart
|
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.Home
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
|
|
@ -26,21 +25,15 @@ import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import com.swoosh.microblog.data.model.FeedPost
|
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.ComposerScreen
|
||||||
import com.swoosh.microblog.ui.composer.ComposerViewModel
|
import com.swoosh.microblog.ui.composer.ComposerViewModel
|
||||||
import com.swoosh.microblog.ui.detail.DetailScreen
|
import com.swoosh.microblog.ui.detail.DetailScreen
|
||||||
import com.swoosh.microblog.ui.feed.FeedScreen
|
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.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.preview.PreviewScreen
|
||||||
import com.swoosh.microblog.ui.settings.SettingsScreen
|
import com.swoosh.microblog.ui.settings.SettingsScreen
|
||||||
import com.swoosh.microblog.ui.setup.SetupScreen
|
import com.swoosh.microblog.ui.setup.SetupScreen
|
||||||
import com.swoosh.microblog.ui.stats.StatsScreen
|
import com.swoosh.microblog.ui.stats.StatsScreen
|
||||||
import com.swoosh.microblog.ui.tags.TagsScreen
|
|
||||||
import com.swoosh.microblog.ui.theme.ThemeViewModel
|
import com.swoosh.microblog.ui.theme.ThemeViewModel
|
||||||
|
|
||||||
object Routes {
|
object Routes {
|
||||||
|
|
@ -53,11 +46,6 @@ object Routes {
|
||||||
const val STATS = "stats"
|
const val STATS = "stats"
|
||||||
const val PREVIEW = "preview"
|
const val PREVIEW = "preview"
|
||||||
const val ADD_ACCOUNT = "add_account"
|
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(
|
data class BottomNavItem(
|
||||||
|
|
@ -68,13 +56,12 @@ data class BottomNavItem(
|
||||||
|
|
||||||
val bottomNavItems = listOf(
|
val bottomNavItems = listOf(
|
||||||
BottomNavItem(Routes.FEED, "Home", Icons.Default.Home),
|
BottomNavItem(Routes.FEED, "Home", Icons.Default.Home),
|
||||||
BottomNavItem(Routes.NEWSLETTER, "Newsletter", Icons.Default.Email),
|
|
||||||
BottomNavItem(Routes.STATS, "Stats", Icons.Default.BarChart),
|
BottomNavItem(Routes.STATS, "Stats", Icons.Default.BarChart),
|
||||||
BottomNavItem(Routes.SETTINGS, "Settings", Icons.Default.Settings)
|
BottomNavItem(Routes.SETTINGS, "Settings", Icons.Default.Settings)
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Routes where the bottom navigation bar should be visible */
|
/** Routes where the bottom navigation bar should be visible */
|
||||||
private val bottomBarRoutes = setOf(Routes.FEED, Routes.NEWSLETTER, Routes.STATS, Routes.SETTINGS)
|
private val bottomBarRoutes = setOf(Routes.FEED, Routes.STATS, Routes.SETTINGS)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SwooshNavGraph(
|
fun SwooshNavGraph(
|
||||||
|
|
@ -86,7 +73,6 @@ fun SwooshNavGraph(
|
||||||
var selectedPost by remember { mutableStateOf<FeedPost?>(null) }
|
var selectedPost by remember { mutableStateOf<FeedPost?>(null) }
|
||||||
var editPost by remember { mutableStateOf<FeedPost?>(null) }
|
var editPost by remember { mutableStateOf<FeedPost?>(null) }
|
||||||
var previewHtml by remember { mutableStateOf("") }
|
var previewHtml by remember { mutableStateOf("") }
|
||||||
var selectedMember by remember { mutableStateOf<GhostMember?>(null) }
|
|
||||||
|
|
||||||
val feedViewModel: FeedViewModel = viewModel()
|
val feedViewModel: FeedViewModel = viewModel()
|
||||||
|
|
||||||
|
|
@ -109,7 +95,6 @@ fun SwooshNavGraph(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (item.route == Routes.FEED) {
|
if (item.route == Routes.FEED) {
|
||||||
feedViewModel.deactivateSearch()
|
feedViewModel.deactivateSearch()
|
||||||
feedViewModel.refreshTagsEnabled()
|
|
||||||
}
|
}
|
||||||
navController.navigate(item.route) {
|
navController.navigate(item.route) {
|
||||||
popUpTo(navController.graph.findStartDestination().id) {
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
|
@ -270,12 +255,6 @@ fun SwooshNavGraph(
|
||||||
navController.navigate(Routes.SETUP) {
|
navController.navigate(Routes.SETUP) {
|
||||||
popUpTo(0) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onNavigateToPages = {
|
|
||||||
navController.navigate(Routes.PAGES)
|
|
||||||
},
|
|
||||||
onNavigateToTags = {
|
|
||||||
navController.navigate(Routes.TAGS)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -287,33 +266,7 @@ fun SwooshNavGraph(
|
||||||
popEnterTransition = { fadeIn(tween(200)) },
|
popEnterTransition = { fadeIn(tween(200)) },
|
||||||
popExitTransition = { fadeOut(tween(150)) }
|
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(
|
composable(
|
||||||
|
|
@ -347,50 +300,6 @@ 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() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,393 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
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,41 +1,24 @@
|
||||||
package com.swoosh.microblog.ui.settings
|
package com.swoosh.microblog.ui.settings
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.BrightnessAuto
|
import androidx.compose.material.icons.filled.BrightnessAuto
|
||||||
import androidx.compose.material.icons.filled.DarkMode
|
import androidx.compose.material.icons.filled.DarkMode
|
||||||
import androidx.compose.material.icons.filled.LightMode
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import com.swoosh.microblog.data.AccountManager
|
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.api.ApiClient
|
||||||
import com.swoosh.microblog.data.model.GhostAccount
|
|
||||||
import com.swoosh.microblog.ui.animation.SwooshMotion
|
import com.swoosh.microblog.ui.animation.SwooshMotion
|
||||||
import com.swoosh.microblog.ui.components.ConfirmationDialog
|
import com.swoosh.microblog.ui.components.ConfirmationDialog
|
||||||
import com.swoosh.microblog.ui.feed.AccountAvatar
|
import com.swoosh.microblog.ui.feed.AccountAvatar
|
||||||
|
|
@ -47,15 +30,11 @@ import com.swoosh.microblog.ui.theme.ThemeViewModel
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
themeViewModel: ThemeViewModel? = null,
|
themeViewModel: ThemeViewModel? = null
|
||||||
onNavigateToPages: () -> Unit = {},
|
|
||||||
onNavigateToTags: () -> Unit = {}
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val accountManager = remember { AccountManager(context) }
|
val accountManager = remember { AccountManager(context) }
|
||||||
val activeAccount = remember { accountManager.getActiveAccount() }
|
val activeAccount = remember { accountManager.getActiveAccount() }
|
||||||
val siteMetadataCache = remember { SiteMetadataCache(context) }
|
|
||||||
val siteData = remember { activeAccount?.let { siteMetadataCache.get(it.id) } }
|
|
||||||
|
|
||||||
val currentThemeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
|
val currentThemeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
|
@ -96,169 +75,6 @@ fun SettingsScreen(
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
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 ---
|
// --- Current Account section ---
|
||||||
Text("Current Account", style = MaterialTheme.typography.titleMedium)
|
Text("Current Account", style = MaterialTheme.typography.titleMedium)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
@ -286,7 +102,10 @@ fun SettingsScreen(
|
||||||
style = MaterialTheme.typography.bodyLarge
|
style = MaterialTheme.typography.bodyLarge
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = activeAccount.blogUrl.toDisplayUrl(),
|
text = activeAccount.blogUrl
|
||||||
|
.removePrefix("https://")
|
||||||
|
.removePrefix("http://")
|
||||||
|
.removeSuffix("/"),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
@ -309,30 +128,6 @@ fun SettingsScreen(
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
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
|
// Disconnect current account
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { showDisconnectDialog = true },
|
onClick = { showDisconnectDialog = true },
|
||||||
|
|
@ -407,69 +202,6 @@ 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
|
@Composable
|
||||||
fun ThemeModeSelector(
|
fun ThemeModeSelector(
|
||||||
currentMode: ThemeMode,
|
currentMode: ThemeMode,
|
||||||
|
|
|
||||||
|
|
@ -9,30 +9,24 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.material.icons.outlined.Info
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.blur
|
import androidx.compose.ui.draw.blur
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
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.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
|
@ -40,7 +34,6 @@ import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -109,124 +102,6 @@ fun SetupScreen(
|
||||||
)
|
)
|
||||||
|
|
||||||
// Content layered on top
|
// 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(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
|
@ -377,7 +252,6 @@ fun SetupScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.swoosh.microblog.data.AccountManager
|
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.ApiClient
|
||||||
import com.swoosh.microblog.data.api.GhostJwtGenerator
|
import com.swoosh.microblog.data.api.GhostJwtGenerator
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
|
|
@ -17,7 +16,6 @@ import kotlinx.coroutines.launch
|
||||||
class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val accountManager = AccountManager(application)
|
private val accountManager = AccountManager(application)
|
||||||
private val siteMetadataCache = SiteMetadataCache(application)
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(SetupUiState(
|
private val _uiState = MutableStateFlow(SetupUiState(
|
||||||
isAddingAccount = accountManager.hasAnyAccount
|
isAddingAccount = accountManager.hasAnyAccount
|
||||||
|
|
@ -36,30 +34,6 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
_uiState.update { it.copy(accountName = name) }
|
_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() {
|
fun save() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
if (state.url.isBlank() || state.apiKey.isBlank()) {
|
if (state.url.isBlank() || state.apiKey.isBlank()) {
|
||||||
|
|
@ -109,39 +83,7 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
accountManager.updateAccount(id = account.id, avatarUrl = avatarUrl)
|
accountManager.updateAccount(id = account.id, avatarUrl = avatarUrl)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { /* avatar is best-effort */ }
|
} 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 ->
|
onFailure = { e ->
|
||||||
// Remove the account since connection failed
|
// Remove the account since connection failed
|
||||||
|
|
@ -168,12 +110,5 @@ data class SetupUiState(
|
||||||
val isTesting: Boolean = false,
|
val isTesting: Boolean = false,
|
||||||
val isSuccess: Boolean = false,
|
val isSuccess: Boolean = false,
|
||||||
val isAddingAccount: 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,40 +1,33 @@
|
||||||
package com.swoosh.microblog.ui.stats
|
package com.swoosh.microblog.ui.stats
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.animateIntAsState
|
import androidx.compose.animation.core.animateIntAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Article
|
import androidx.compose.material.icons.automirrored.filled.Article
|
||||||
import androidx.compose.material.icons.filled.*
|
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.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.swoosh.microblog.data.model.GhostTagFull
|
|
||||||
import com.swoosh.microblog.ui.tags.parseHexColor
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun StatsScreen(
|
fun StatsScreen(
|
||||||
viewModel: StatsViewModel = viewModel(),
|
viewModel: StatsViewModel = viewModel()
|
||||||
onNavigateToMembers: (() -> Unit)? = null
|
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
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(
|
val animatedTotal by animateIntAsState(
|
||||||
targetValue = state.stats.totalPosts,
|
targetValue = state.stats.totalPosts,
|
||||||
animationSpec = tween(400),
|
animationSpec = tween(400),
|
||||||
|
|
@ -56,39 +49,6 @@ fun StatsScreen(
|
||||||
label = "scheduled"
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -154,57 +114,6 @@ fun StatsScreen(
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
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
|
// Writing stats section
|
||||||
Text(
|
Text(
|
||||||
"Writing Stats",
|
"Writing Stats",
|
||||||
|
|
@ -231,80 +140,6 @@ 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) {
|
if (state.error != null) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -317,77 +152,6 @@ 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
|
@Composable
|
||||||
private fun StatsCard(
|
private fun StatsCard(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -424,42 +188,6 @@ 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
|
@Composable
|
||||||
private fun WritingStatRow(label: String, value: String) {
|
private fun WritingStatRow(label: String, value: String) {
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,8 @@ import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.swoosh.microblog.data.model.FeedPost
|
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.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.PostRepository
|
||||||
import com.swoosh.microblog.data.repository.TagRepository
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -20,8 +15,6 @@ import kotlinx.coroutines.launch
|
||||||
class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val repository = PostRepository(application)
|
private val repository = PostRepository(application)
|
||||||
private val memberRepository = MemberRepository(application)
|
|
||||||
private val tagRepository = TagRepository(application)
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(StatsUiState())
|
private val _uiState = MutableStateFlow(StatsUiState())
|
||||||
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
|
||||||
|
|
@ -35,94 +28,52 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
_uiState.update { it.copy(isLoading = true) }
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Launch posts, members, and tags fetches in parallel
|
// Get local posts
|
||||||
val postsDeferred = async {
|
val localPosts = repository.getAllLocalPostsList()
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
val membersDeferred = async {
|
// Get remote posts
|
||||||
try {
|
val remotePosts = mutableListOf<FeedPost>()
|
||||||
val membersResult = memberRepository.fetchAllMembers()
|
var page = 1
|
||||||
membersResult.getOrNull()?.let { members ->
|
var hasMore = true
|
||||||
memberRepository.getMemberStats(members)
|
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
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
)
|
||||||
null
|
// Safety limit
|
||||||
}
|
if (page > 20) break
|
||||||
}
|
}
|
||||||
|
|
||||||
val tagsDeferred = async {
|
// Remove remote duplicates that exist locally
|
||||||
try {
|
val localGhostIds = localPosts.mapNotNull { it.ghostId }.toSet()
|
||||||
tagRepository.fetchTags().getOrNull()
|
val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds }
|
||||||
?.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)
|
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) {
|
} catch (e: Exception) {
|
||||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||||
}
|
}
|
||||||
|
|
@ -132,10 +83,6 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
data class StatsUiState(
|
data class StatsUiState(
|
||||||
val stats: OverallStats = OverallStats(),
|
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 isLoading: Boolean = false,
|
||||||
val error: String? = null
|
val error: String? = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,448 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
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,8 +18,6 @@ class PostUploadWorker(
|
||||||
workerParams: WorkerParameters
|
workerParams: WorkerParameters
|
||||||
) : CoroutineWorker(context, workerParams) {
|
) : CoroutineWorker(context, workerParams) {
|
||||||
|
|
||||||
private val gson = Gson()
|
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
val repository = PostRepository(applicationContext)
|
val repository = PostRepository(applicationContext)
|
||||||
val queuedPosts = repository.getQueuedPosts()
|
val queuedPosts = repository.getQueuedPosts()
|
||||||
|
|
@ -68,73 +66,25 @@ class PostUploadWorker(
|
||||||
featureImage = allImageUrls.first()
|
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(
|
val mobiledoc = MobiledocBuilder.build(
|
||||||
text = post.content,
|
post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription,
|
||||||
imageUrls = allImageUrls,
|
post.imageAlt
|
||||||
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
|
// Parse tags from JSON stored in LocalPost
|
||||||
val tagNames: List<String> = try {
|
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) {
|
} catch (e: Exception) {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
val ghostTags = tagNames.map { GhostTag(name = it) }
|
val ghostTags = tagNames.map { GhostTag(name = it) }
|
||||||
|
|
||||||
val isEmailOnly = post.queueStatus == QueueStatus.QUEUED_EMAIL_ONLY
|
|
||||||
|
|
||||||
val ghostPost = GhostPost(
|
val ghostPost = GhostPost(
|
||||||
title = post.title,
|
title = post.title,
|
||||||
mobiledoc = mobiledoc,
|
mobiledoc = mobiledoc,
|
||||||
status = when (post.queueStatus) {
|
status = when (post.queueStatus) {
|
||||||
QueueStatus.QUEUED_PUBLISH -> "published"
|
QueueStatus.QUEUED_PUBLISH -> "published"
|
||||||
QueueStatus.QUEUED_SCHEDULED -> "scheduled"
|
QueueStatus.QUEUED_SCHEDULED -> "scheduled"
|
||||||
QueueStatus.QUEUED_EMAIL_ONLY -> "published"
|
|
||||||
else -> "draft"
|
else -> "draft"
|
||||||
},
|
},
|
||||||
featured = post.featured,
|
featured = post.featured,
|
||||||
|
|
@ -142,17 +92,13 @@ class PostUploadWorker(
|
||||||
feature_image_alt = post.imageAlt,
|
feature_image_alt = post.imageAlt,
|
||||||
published_at = post.scheduledAt,
|
published_at = post.scheduledAt,
|
||||||
visibility = "public",
|
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) {
|
val result = if (post.ghostId != null) {
|
||||||
repository.updatePost(post.ghostId, ghostPost, newsletter = newsletterSlug)
|
repository.updatePost(post.ghostId, ghostPost)
|
||||||
} else {
|
} else {
|
||||||
repository.createPost(ghostPost, newsletter = newsletterSlug)
|
repository.createPost(ghostPost)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.fold(
|
result.fold(
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with separate params and no link produces same as null preview`() {
|
fun `build with separate params and no link produces same as null preview`() {
|
||||||
val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?)
|
val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?)
|
||||||
val resultB = MobiledocBuilder.build("Hello")
|
val resultB = MobiledocBuilder.build("Hello", null, null, null)
|
||||||
assertEquals(resultA, resultB)
|
assertEquals(resultA, resultB)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,9 +207,9 @@ class MobiledocBuilderTest {
|
||||||
fun `build with separate params includes link data`() {
|
fun `build with separate params includes link data`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Text",
|
"Text",
|
||||||
linkUrl = "https://test.com",
|
"https://test.com",
|
||||||
linkTitle = "Test Title",
|
"Test Title",
|
||||||
linkDescription = "Test Desc"
|
"Test Desc"
|
||||||
)
|
)
|
||||||
assertTrue(result.contains("https://test.com"))
|
assertTrue(result.contains("https://test.com"))
|
||||||
assertTrue(result.contains("Test Title"))
|
assertTrue(result.contains("Test Title"))
|
||||||
|
|
@ -219,7 +219,7 @@ class MobiledocBuilderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `build with separate params handles null title and description`() {
|
fun `build with separate params handles null title and description`() {
|
||||||
val result = MobiledocBuilder.build("Text", linkUrl = "https://test.com")
|
val result = MobiledocBuilder.build("Text", "https://test.com", null, null)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
assertNotNull(json)
|
assertNotNull(json)
|
||||||
assertTrue(result.contains("bookmark"))
|
assertTrue(result.contains("bookmark"))
|
||||||
|
|
@ -257,9 +257,8 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with image card produces valid JSON`() {
|
fun `build with image card produces valid JSON`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Post text",
|
"Post text", null, null, null,
|
||||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
"https://example.com/photo.jpg", "A sunset"
|
||||||
imageAlt = "A sunset"
|
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
assertNotNull(json)
|
assertNotNull(json)
|
||||||
|
|
@ -268,9 +267,8 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with image card includes image type`() {
|
fun `build with image card includes image type`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Text",
|
"Text", null, null, null,
|
||||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
"https://example.com/photo.jpg", "Alt text"
|
||||||
imageAlt = "Alt text"
|
|
||||||
)
|
)
|
||||||
assertTrue("Should contain image card type", result.contains("\"image\""))
|
assertTrue("Should contain image card type", result.contains("\"image\""))
|
||||||
}
|
}
|
||||||
|
|
@ -278,9 +276,8 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with image card includes src`() {
|
fun `build with image card includes src`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Text",
|
"Text", null, null, null,
|
||||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
"https://example.com/photo.jpg", "Alt text"
|
||||||
imageAlt = "Alt text"
|
|
||||||
)
|
)
|
||||||
assertTrue("Should contain image src", result.contains("https://example.com/photo.jpg"))
|
assertTrue("Should contain image src", result.contains("https://example.com/photo.jpg"))
|
||||||
}
|
}
|
||||||
|
|
@ -288,9 +285,8 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with image card includes alt text`() {
|
fun `build with image card includes alt text`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Text",
|
"Text", null, null, null,
|
||||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
"https://example.com/photo.jpg", "A beautiful sunset"
|
||||||
imageAlt = "A beautiful sunset"
|
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
val cards = json.getAsJsonArray("cards")
|
val cards = json.getAsJsonArray("cards")
|
||||||
|
|
@ -304,8 +300,8 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with image card and null alt uses empty string`() {
|
fun `build with image card and null alt uses empty string`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Text",
|
"Text", null, null, null,
|
||||||
imageUrls = listOf("https://example.com/photo.jpg")
|
"https://example.com/photo.jpg", null
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
val cards = json.getAsJsonArray("cards")
|
val cards = json.getAsJsonArray("cards")
|
||||||
|
|
@ -317,9 +313,8 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with image card includes caption field`() {
|
fun `build with image card includes caption field`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Text",
|
"Text", null, null, null,
|
||||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
"https://example.com/photo.jpg", "Alt"
|
||||||
imageAlt = "Alt"
|
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
val card = json.getAsJsonArray("cards").get(0).asJsonArray
|
val card = json.getAsJsonArray("cards").get(0).asJsonArray
|
||||||
|
|
@ -330,9 +325,8 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with image card has card section`() {
|
fun `build with image card has card section`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Text",
|
"Text", null, null, null,
|
||||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
"https://example.com/photo.jpg", "Alt"
|
||||||
imageAlt = "Alt"
|
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
val sections = json.getAsJsonArray("sections")
|
val sections = json.getAsJsonArray("sections")
|
||||||
|
|
@ -342,12 +336,8 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with image and link has both cards`() {
|
fun `build with image and link has both cards`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Text",
|
"Text", "https://link.com", "Link Title", "Link Desc",
|
||||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
"https://example.com/photo.jpg", "Image alt"
|
||||||
linkUrl = "https://link.com",
|
|
||||||
linkTitle = "Link Title",
|
|
||||||
linkDescription = "Link Desc",
|
|
||||||
imageAlt = "Image alt"
|
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
val cards = json.getAsJsonArray("cards")
|
val cards = json.getAsJsonArray("cards")
|
||||||
|
|
@ -360,12 +350,8 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with image and link has three sections`() {
|
fun `build with image and link has three sections`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Text",
|
"Text", "https://link.com", "Title", "Desc",
|
||||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
"https://example.com/photo.jpg", "Alt"
|
||||||
linkUrl = "https://link.com",
|
|
||||||
linkTitle = "Title",
|
|
||||||
linkDescription = "Desc",
|
|
||||||
imageAlt = "Alt"
|
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
val sections = json.getAsJsonArray("sections")
|
val sections = json.getAsJsonArray("sections")
|
||||||
|
|
@ -375,9 +361,8 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with image card escapes alt text`() {
|
fun `build with image card escapes alt text`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Text",
|
"Text", null, null, null,
|
||||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
"https://example.com/photo.jpg", "He said \"hello\""
|
||||||
imageAlt = "He said \"hello\""
|
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
assertNotNull("Should produce valid JSON with escaped alt text", json)
|
assertNotNull("Should produce valid JSON with escaped alt text", json)
|
||||||
|
|
@ -385,7 +370,10 @@ class MobiledocBuilderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `build without image produces no image card`() {
|
fun `build without image produces no image card`() {
|
||||||
val result = MobiledocBuilder.build("Text")
|
val result = MobiledocBuilder.build(
|
||||||
|
"Text", null, null, null,
|
||||||
|
null, null
|
||||||
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
assertTrue("Should have no cards", json.getAsJsonArray("cards").isEmpty)
|
assertTrue("Should have no cards", json.getAsJsonArray("cards").isEmpty)
|
||||||
}
|
}
|
||||||
|
|
@ -393,9 +381,8 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with image card section references correct card index`() {
|
fun `build with image card section references correct card index`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Text",
|
"Text", null, null, null,
|
||||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
"https://example.com/photo.jpg", "Alt"
|
||||||
imageAlt = "Alt"
|
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
val sections = json.getAsJsonArray("sections")
|
val sections = json.getAsJsonArray("sections")
|
||||||
|
|
@ -407,11 +394,8 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with image and link card sections reference correct indices`() {
|
fun `build with image and link card sections reference correct indices`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Text",
|
"Text", "https://link.com", "Title", null,
|
||||||
imageUrls = listOf("https://example.com/photo.jpg"),
|
"https://example.com/photo.jpg", "Alt"
|
||||||
linkUrl = "https://link.com",
|
|
||||||
linkTitle = "Title",
|
|
||||||
imageAlt = "Alt"
|
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
val sections = json.getAsJsonArray("sections")
|
val sections = json.getAsJsonArray("sections")
|
||||||
|
|
@ -430,7 +414,7 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with single image produces valid JSON`() {
|
fun `build with single image produces valid JSON`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Hello", listOf("https://example.com/img.jpg")
|
"Hello", listOf("https://example.com/img.jpg"), null, null, null
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
assertNotNull(json)
|
assertNotNull(json)
|
||||||
|
|
@ -439,7 +423,7 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with single image has one image card`() {
|
fun `build with single image has one image card`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Hello", listOf("https://example.com/img.jpg")
|
"Hello", listOf("https://example.com/img.jpg"), null, null, null
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
assertEquals(1, json.getAsJsonArray("cards").size())
|
assertEquals(1, json.getAsJsonArray("cards").size())
|
||||||
|
|
@ -452,7 +436,7 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with single image has two sections`() {
|
fun `build with single image has two sections`() {
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Hello", listOf("https://example.com/img.jpg")
|
"Hello", listOf("https://example.com/img.jpg"), null, null, null
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
assertEquals(2, json.getAsJsonArray("sections").size())
|
assertEquals(2, json.getAsJsonArray("sections").size())
|
||||||
|
|
@ -465,7 +449,7 @@ class MobiledocBuilderTest {
|
||||||
"https://example.com/img2.jpg",
|
"https://example.com/img2.jpg",
|
||||||
"https://example.com/img3.jpg"
|
"https://example.com/img3.jpg"
|
||||||
)
|
)
|
||||||
val result = MobiledocBuilder.build("Hello", images)
|
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
assertNotNull(json)
|
assertNotNull(json)
|
||||||
}
|
}
|
||||||
|
|
@ -477,7 +461,7 @@ class MobiledocBuilderTest {
|
||||||
"https://example.com/img2.jpg",
|
"https://example.com/img2.jpg",
|
||||||
"https://example.com/img3.jpg"
|
"https://example.com/img3.jpg"
|
||||||
)
|
)
|
||||||
val result = MobiledocBuilder.build("Hello", images)
|
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
assertEquals(3, json.getAsJsonArray("cards").size())
|
assertEquals(3, json.getAsJsonArray("cards").size())
|
||||||
}
|
}
|
||||||
|
|
@ -488,7 +472,7 @@ class MobiledocBuilderTest {
|
||||||
"https://example.com/img1.jpg",
|
"https://example.com/img1.jpg",
|
||||||
"https://example.com/img2.jpg"
|
"https://example.com/img2.jpg"
|
||||||
)
|
)
|
||||||
val result = MobiledocBuilder.build("Hello", images)
|
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
// 1 text section + 2 card sections
|
// 1 text section + 2 card sections
|
||||||
assertEquals(3, json.getAsJsonArray("sections").size())
|
assertEquals(3, json.getAsJsonArray("sections").size())
|
||||||
|
|
@ -500,7 +484,7 @@ class MobiledocBuilderTest {
|
||||||
"https://example.com/img1.jpg",
|
"https://example.com/img1.jpg",
|
||||||
"https://example.com/img2.jpg"
|
"https://example.com/img2.jpg"
|
||||||
)
|
)
|
||||||
val result = MobiledocBuilder.build("Hello", images)
|
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
val cards = json.getAsJsonArray("cards")
|
val cards = json.getAsJsonArray("cards")
|
||||||
for (i in 0 until cards.size()) {
|
for (i in 0 until cards.size()) {
|
||||||
|
|
@ -516,7 +500,7 @@ class MobiledocBuilderTest {
|
||||||
"https://example.com/img2.jpg",
|
"https://example.com/img2.jpg",
|
||||||
"https://example.com/img3.jpg"
|
"https://example.com/img3.jpg"
|
||||||
)
|
)
|
||||||
val result = MobiledocBuilder.build("Hello", images)
|
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
val cards = json.getAsJsonArray("cards")
|
val cards = json.getAsJsonArray("cards")
|
||||||
assertEquals("https://example.com/img1.jpg", cards.get(0).asJsonArray.get(1).asJsonObject.get("src").asString)
|
assertEquals("https://example.com/img1.jpg", cards.get(0).asJsonArray.get(1).asJsonObject.get("src").asString)
|
||||||
|
|
@ -528,7 +512,7 @@ class MobiledocBuilderTest {
|
||||||
fun `build with images and link has both image and bookmark cards`() {
|
fun `build with images and link has both image and bookmark cards`() {
|
||||||
val images = listOf("https://example.com/img1.jpg")
|
val images = listOf("https://example.com/img1.jpg")
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Hello", images, linkUrl = "https://example.com", linkTitle = "Title", linkDescription = "Desc"
|
"Hello", images, "https://example.com", "Title", "Desc"
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
val cards = json.getAsJsonArray("cards")
|
val cards = json.getAsJsonArray("cards")
|
||||||
|
|
@ -544,7 +528,7 @@ class MobiledocBuilderTest {
|
||||||
fun `build with images and link has correct number of sections`() {
|
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 images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
|
||||||
val result = MobiledocBuilder.build(
|
val result = MobiledocBuilder.build(
|
||||||
"Hello", images, linkUrl = "https://example.com", linkTitle = "Title", linkDescription = "Desc"
|
"Hello", images, "https://example.com", "Title", "Desc"
|
||||||
)
|
)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
// 1 text section + 2 image card sections + 1 bookmark card section
|
// 1 text section + 2 image card sections + 1 bookmark card section
|
||||||
|
|
@ -554,7 +538,7 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with images card sections reference correct card indices`() {
|
fun `build with images card sections reference correct card indices`() {
|
||||||
val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
|
val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
|
||||||
val result = MobiledocBuilder.build("Hello", images)
|
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
val sections = json.getAsJsonArray("sections")
|
val sections = json.getAsJsonArray("sections")
|
||||||
|
|
||||||
|
|
@ -572,7 +556,7 @@ class MobiledocBuilderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `build with empty image list produces no image cards`() {
|
fun `build with empty image list produces no image cards`() {
|
||||||
val result = MobiledocBuilder.build("Hello", emptyList())
|
val result = MobiledocBuilder.build("Hello", emptyList(), null, null, null)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
assertTrue(json.getAsJsonArray("cards").isEmpty)
|
assertTrue(json.getAsJsonArray("cards").isEmpty)
|
||||||
assertEquals(1, json.getAsJsonArray("sections").size())
|
assertEquals(1, json.getAsJsonArray("sections").size())
|
||||||
|
|
@ -581,14 +565,14 @@ class MobiledocBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `build with empty image list matches no-image build`() {
|
fun `build with empty image list matches no-image build`() {
|
||||||
val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?)
|
val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?)
|
||||||
val resultB = MobiledocBuilder.build("Hello", emptyList())
|
val resultB = MobiledocBuilder.build("Hello", emptyList(), null, null, null)
|
||||||
assertEquals(resultA, resultB)
|
assertEquals(resultA, resultB)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `build with image URL containing special chars produces valid JSON`() {
|
fun `build with image URL containing special chars produces valid JSON`() {
|
||||||
val images = listOf("https://example.com/img?id=1&name=\"test\"")
|
val images = listOf("https://example.com/img?id=1&name=\"test\"")
|
||||||
val result = MobiledocBuilder.build("Hello", images)
|
val result = MobiledocBuilder.build("Hello", images, null, null, null)
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
assertNotNull(json)
|
assertNotNull(json)
|
||||||
}
|
}
|
||||||
|
|
@ -601,7 +585,7 @@ class MobiledocBuilderTest {
|
||||||
"https://example.com/img1.jpg",
|
"https://example.com/img1.jpg",
|
||||||
"https://example.com/img2.jpg"
|
"https://example.com/img2.jpg"
|
||||||
)
|
)
|
||||||
val result = MobiledocBuilder.build("Text", images, imageAlt = "First image alt")
|
val result = MobiledocBuilder.build("Text", images, null, null, null, "First image alt")
|
||||||
val json = JsonParser.parseString(result).asJsonObject
|
val json = JsonParser.parseString(result).asJsonObject
|
||||||
val cards = json.getAsJsonArray("cards")
|
val cards = json.getAsJsonArray("cards")
|
||||||
|
|
||||||
|
|
@ -613,249 +597,4 @@ class MobiledocBuilderTest {
|
||||||
val secondCard = cards.get(1).asJsonArray.get(1).asJsonObject
|
val secondCard = cards.get(1).asJsonArray.get(1).asJsonObject
|
||||||
assertEquals("", secondCard.get("alt").asString)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
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
|
@Test(expected = IllegalArgumentException::class)
|
||||||
fun `toPostStatus returns DRAFT fallback on invalid string`() {
|
fun `toPostStatus throws on invalid string`() {
|
||||||
assertEquals(PostStatus.DRAFT, converters.toPostStatus("INVALID"))
|
converters.toPostStatus("INVALID")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- QueueStatus conversions ---
|
// --- QueueStatus conversions ---
|
||||||
|
|
@ -112,9 +112,9 @@ class ConvertersTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test(expected = IllegalArgumentException::class)
|
||||||
fun `toQueueStatus returns NONE fallback on invalid string`() {
|
fun `toQueueStatus throws on invalid string`() {
|
||||||
assertEquals(QueueStatus.NONE, converters.toQueueStatus("NONEXISTENT"))
|
converters.toQueueStatus("NONEXISTENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- String list JSON serialization ---
|
// --- String list JSON serialization ---
|
||||||
|
|
|
||||||
|
|
@ -265,13 +265,13 @@ class GhostModelsTest {
|
||||||
// --- Enum values ---
|
// --- Enum values ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `PostStatus has exactly 4 values`() {
|
fun `PostStatus has exactly 3 values`() {
|
||||||
assertEquals(4, PostStatus.values().size)
|
assertEquals(3, PostStatus.values().size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `QueueStatus has exactly 6 values`() {
|
fun `QueueStatus has exactly 5 values`() {
|
||||||
assertEquals(6, QueueStatus.values().size)
|
assertEquals(5, QueueStatus.values().size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -279,7 +279,6 @@ class GhostModelsTest {
|
||||||
assertEquals(PostStatus.DRAFT, PostStatus.valueOf("DRAFT"))
|
assertEquals(PostStatus.DRAFT, PostStatus.valueOf("DRAFT"))
|
||||||
assertEquals(PostStatus.PUBLISHED, PostStatus.valueOf("PUBLISHED"))
|
assertEquals(PostStatus.PUBLISHED, PostStatus.valueOf("PUBLISHED"))
|
||||||
assertEquals(PostStatus.SCHEDULED, PostStatus.valueOf("SCHEDULED"))
|
assertEquals(PostStatus.SCHEDULED, PostStatus.valueOf("SCHEDULED"))
|
||||||
assertEquals(PostStatus.SENT, PostStatus.valueOf("SENT"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -287,7 +286,6 @@ class GhostModelsTest {
|
||||||
assertEquals(QueueStatus.NONE, QueueStatus.valueOf("NONE"))
|
assertEquals(QueueStatus.NONE, QueueStatus.valueOf("NONE"))
|
||||||
assertEquals(QueueStatus.QUEUED_PUBLISH, QueueStatus.valueOf("QUEUED_PUBLISH"))
|
assertEquals(QueueStatus.QUEUED_PUBLISH, QueueStatus.valueOf("QUEUED_PUBLISH"))
|
||||||
assertEquals(QueueStatus.QUEUED_SCHEDULED, QueueStatus.valueOf("QUEUED_SCHEDULED"))
|
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.UPLOADING, QueueStatus.valueOf("UPLOADING"))
|
||||||
assertEquals(QueueStatus.FAILED, QueueStatus.valueOf("FAILED"))
|
assertEquals(QueueStatus.FAILED, QueueStatus.valueOf("FAILED"))
|
||||||
}
|
}
|
||||||
|
|
@ -424,95 +422,4 @@ class GhostModelsTest {
|
||||||
val json = gson.toJson(wrapper)
|
val json = gson.toJson(wrapper)
|
||||||
assertTrue(json.contains("\"feature_image_alt\":\"Photo description\""))
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,280 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
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 ---
|
// --- Enum values ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `PostFilter has exactly 5 values`() {
|
fun `PostFilter has exactly 4 values`() {
|
||||||
assertEquals(5, PostFilter.values().size)
|
assertEquals(4, PostFilter.values().size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -18,7 +18,6 @@ class PostFilterTest {
|
||||||
assertEquals(PostFilter.PUBLISHED, PostFilter.valueOf("PUBLISHED"))
|
assertEquals(PostFilter.PUBLISHED, PostFilter.valueOf("PUBLISHED"))
|
||||||
assertEquals(PostFilter.DRAFT, PostFilter.valueOf("DRAFT"))
|
assertEquals(PostFilter.DRAFT, PostFilter.valueOf("DRAFT"))
|
||||||
assertEquals(PostFilter.SCHEDULED, PostFilter.valueOf("SCHEDULED"))
|
assertEquals(PostFilter.SCHEDULED, PostFilter.valueOf("SCHEDULED"))
|
||||||
assertEquals(PostFilter.SENT, PostFilter.valueOf("SENT"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Display names ---
|
// --- Display names ---
|
||||||
|
|
@ -108,26 +107,4 @@ class PostFilterTest {
|
||||||
fun `SCHEDULED emptyMessage returns No scheduled posts yet`() {
|
fun `SCHEDULED emptyMessage returns No scheduled posts yet`() {
|
||||||
assertEquals("No scheduled posts yet", PostFilter.SCHEDULED.emptyMessage())
|
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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,371 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
BIN
pics/feed.png
BIN
pics/feed.png
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 174 KiB |
BIN
pics/stats.png
BIN
pics/stats.png
Binary file not shown.
|
Before Width: | Height: | Size: 133 KiB |
Loading…
Reference in a new issue