Compare commits

...

49 commits

Author SHA1 Message Date
Paweł Orzech
2cb4ad7953
docs: update README with screenshots and new features
Add app screenshots (feed, stats, newsletter, settings) and update
README and CLAUDE.md to reflect v0.3.0 changes: newsletter integration,
static pages, members management, tag management, pin posts, 4-tab
bottom nav, 31 test classes, and Media3 in tech stack.
2026-03-20 09:39:42 +01:00
Paweł Orzech
84408075b7
fix: extract images from HTML content when feature_image and mobiledoc cards are empty
Posts may have images embedded in HTML (via html/markdown cards) without
a feature_image or explicit image card. Fall back to parsing <img src>
from HTML content to display these images.
2026-03-20 09:32:15 +01:00
Paweł Orzech
d079fc9ba8
fix: center Newsletter screen title and disabled state text 2026-03-20 09:28:26 +01:00
Paweł Orzech
289082cb74
fix: tags pill not hiding when tags disabled in Settings
- Initialize _tagsEnabled from preferences (was hardcoded true)
- Add refreshTagsEnabled() called when navigating to Home tab
- Immediately clears popularTags and activeTagFilter when disabled
2026-03-20 09:25:13 +01:00
Paweł Orzech
0718a9e744
feat: add tags toggle in Settings, move newsletter to bottom tab
- Add TagsPreferences with per-account toggle (enabled by default)
- Tags toggle in Settings → Features section with "Manage Tags" button
- When tags disabled: hide tag filter chips, tag section in Composer,
  tag click handlers become no-ops in Feed
- New Newsletter bottom tab (Home, Newsletter, Stats, Settings)
- NewsletterScreen shows enable toggle, subscriber count, newsletters list
- Remove newsletter section from Settings (moved to dedicated tab)
2026-03-20 09:18:30 +01:00
Paweł Orzech
da8a90470d
refactor: simplify code after /simplify review
- Parallelize StatsViewModel fetches (posts, members, tags via async/await)
- Collapse MobiledocBuilder overloads into single function with defaults
- Extract shared FileTypeColor composable from duplicated color mappings
- Remove redundant state in FeedViewModel and FeedScreen
- Unify formatFileSize usage, remove inline duplication
- Fix minor issues in MediaPlayers, PostUploadWorker, Pages, Settings
2026-03-20 09:05:22 +01:00
Paweł Orzech
29927a7638 feat: bump version to v0.3.0 (versionCode 3)
New features: Site Metadata, Tags CRUD, Members API,
Newsletter Sending, Email-only Posts, Media Upload,
File Upload, Pages API.
2026-03-20 01:00:40 +01:00
Paweł Orzech
e71d15805c merge: integrate Phase 4b (Email-only Posts) - all 8 features complete 2026-03-20 00:59:58 +01:00
Paweł Orzech
5c931b138c feat: handle email-only posts in PostUploadWorker, Feed, and Detail screens
Phase 4b.3: Sent status in Feed + PostUploadWorker handling.
- PostUploadWorker: handle QUEUED_EMAIL_ONLY with email_only=true on GhostPost,
  pass newsletter slug to repository.createPost()
- FeedViewModel: map GhostPost email_only/sent status to FeedPost.emailOnly
- FeedScreen FilterChipsBar: add "Sent" chip (magenta, only when newsletter enabled)
- FeedScreen PostCardContent: show envelope icon + "Sent" in magenta for sent posts,
  replace "Share" with "Copy content" for email-only posts
- FeedScreen StatusBadge: handle sent/emailOnly status
- DetailScreen: show email-only info card with errorContainer color when post is
  sent via email only, noting it's not visible on the blog
2026-03-20 00:58:50 +01:00
Paweł Orzech
0c43dc173c merge: integrate Phase 5 (Media Upload) with existing phases 2026-03-20 00:57:01 +01:00
Paweł Orzech
f93a21e743 feat: add email-only post option in Composer with confirmation dialog
Phase 4b.2: Email-only option in Composer.
- Add "Send via Email Only" dropdown menu item (visible when newsletter enabled)
- Add showEmailOnlyConfirmation state to ComposerUiState
- Add sendEmailOnly(), confirmEmailOnly(), cancelEmailOnly() to ViewModel
- submitEmailOnlyPost() saves with emailOnly=true, QUEUED_EMAIL_ONLY status
- Add EmailOnlyConfirmationDialog with warning icon, post preview,
  newsletter picker (if multiple), bold warning about irreversibility,
  and error-colored confirm button
2026-03-20 00:56:04 +01:00
Paweł Orzech
f9d060ed7d feat: add SENT status, QUEUED_EMAIL_ONLY queue status, and email_only field for email-only posts
Phase 4b.1: Add data model support for email-only posts.
- PostStatus: add SENT enum value
- QueueStatus: add QUEUED_EMAIL_ONLY enum value
- PostFilter: add SENT filter with "status:sent" ghost filter
- GhostPost: add email_only Boolean field
- FeedPost: add emailOnly Boolean field
- LocalPostDao: include QUEUED_EMAIL_ONLY in queued posts query
- OverallStats: handle SENT status in stats calculation
- FeedScreen: show "Pending email send" for QUEUED_EMAIL_ONLY queue status
- Update tests for new enum values and fields
2026-03-20 00:54:34 +01:00
Paweł Orzech
3b1061694d merge: integrate Phase 4a (Newsletter) with existing phases 2026-03-20 00:50:17 +01:00
Paweł Orzech
a1aae661c9 feat: add video/audio playback in Feed and Detail screens
- Add Media3 ExoPlayer dependencies (media3-exoplayer, media3-ui 1.2.1)
- Extend FeedPost with videoUrl and audioUrl fields
- Parse video/audio card URLs from mobiledoc JSON in FeedViewModel
- Map LocalPost video/audio URIs to FeedPost in toFeedPost()
- Create VideoPlayer composable: ExoPlayer in AndroidView, play button overlay, tap to play
- Create AudioPlayer composable: play/pause button, progress slider, duration text
- Integrate compact VideoPlayer and AudioPlayer in FeedScreen post cards
- Integrate full-size VideoPlayer and AudioPlayer in DetailScreen with reveal animations
- Load video/audio URIs when editing a post in ComposerViewModel
2026-03-20 00:49:24 +01:00
Paweł Orzech
39a51e5d4b feat: add newsletter sending toggle in Composer publish dialog
- Add newsletter fields to ComposerUiState (enabled, newsletters list,
  selected newsletter, sendAsNewsletter, emailSegment, subscriber count,
  confirmation dialog state)
- Load newsletter data on init when newsletter features are enabled
- Add newsletter options in publish dropdown: send-as-newsletter switch,
  newsletter picker (radio buttons), email segment picker (All/Free/Paid)
- Show warning about irreversible email send with subscriber count
- Change publish button to tertiaryContainer color and email icon when
  newsletter sending is active
- Add NewsletterConfirmationDialog requiring "WYSLIJ" typed input to
  confirm, with summary of newsletter name, segment, count, and title
- Pass newsletter slug and email segment through to PostRepository
- Store newsletter slug in LocalPost for offline queue support
2026-03-20 00:48:18 +01:00
Paweł Orzech
c55881e7a8 feat: display file attachments in Feed and Detail screens
- Extend FeedPost with fileUrl and fileName fields
- Parse mobiledoc file cards in FeedViewModel.extractFileCardFromMobiledoc()
- Map LocalPost file fields to FeedPost in toFeedPost()
- Create FileAttachmentCard composable with file type icon colors and tap-to-download
- Integrate file card into PostCardContent (FeedScreen) and DetailScreen
2026-03-20 00:48:01 +01:00
Paweł Orzech
27782893dc feat: add video and audio picker buttons and upload support in Composer
- Add videoUri, audioUri, uploadedVideoUrl, uploadedAudioUrl, isUploadingMedia to ComposerUiState
- Add setVideo/removeVideo/setAudio/removeAudio methods to ComposerViewModel
- Update submitPost to upload video/audio via uploadMediaFile and pass URLs to MobiledocBuilder
- Save videoUri/audioUri to LocalPost for offline queue
- Add Video and Audio picker buttons to composer toolbar
- Add MediaPreviewCard composable showing filename, file size, and remove button
- Update PostUploadWorker to upload video/audio before building mobiledoc
2026-03-20 00:46:27 +01:00
Paweł Orzech
74dac1db6f feat: add file attachment support in Composer, MobiledocBuilder, and PostUploadWorker
- Add file card support to MobiledocBuilder with Ghost's native file card format
- Add file card tests to MobiledocBuilderTest
- Add file state fields to ComposerUiState (fileUri, fileName, fileSize, fileMimeType, uploadedFileUrl)
- Add addFile()/removeFile() methods to ComposerViewModel with 50MB size validation
- Add file picker button and FileAttachmentComposerCard in ComposerScreen
- Update PostUploadWorker to upload files via repository.uploadFile() and include in mobiledoc
2026-03-20 00:46:06 +01:00
Paweł Orzech
bbe991b027 feat: add newsletter toggle in Settings screen
- Add "Newsletter" section after Content/Tags section
- Switch to enable/disable newsletter features per account
- Info text explaining the toggle's effect on composer
- Best-effort API validation when toggling ON (fetches newsletters count)
- Animated validation status display
2026-03-20 00:44:58 +01:00
Paweł Orzech
96e2799787 feat: add video and audio card support to MobiledocBuilder
- Add 8-param build() overload accepting videoUrl and audioUrl
- Video card: ["video",{"src":"url","loop":false}]
- Audio card: ["audio",{"src":"url"}]
- Card order: images -> video -> audio -> bookmark
- Add tests for video only, audio only, and all media types combined
2026-03-20 00:44:02 +01:00
Paweł Orzech
ed11577be1 feat: add newsletter model, API endpoint, and per-account preferences
- Create NewsletterModels.kt with GhostNewsletter and NewslettersResponse
- Add getNewsletters() endpoint to GhostApiService
- Add optional newsletter/emailSegment query params to createPost/updatePost
- Create NewsletterPreferences for per-account newsletter toggle
- Add fetchNewsletters() and fetchSubscriberCount() to PostRepository
- Pass newsletter params through PostRepository to API service
- Add Robolectric tests for NewsletterPreferences
2026-03-20 00:43:53 +01:00
Paweł Orzech
2f9b7dac09 feat: add file upload API endpoint, model, and repository method
- Create FileModels.kt with FileUploadResponse and UploadedFile data classes
- Add uploadFile() multipart endpoint to GhostApiService
- Add uploadFile(uri) method to PostRepository following the same pattern as uploadImage()
2026-03-20 00:43:10 +01:00
Paweł Orzech
2410d05bd6 feat: add media upload API endpoints and repository method for video/audio
- Create MediaModels.kt with MediaUploadResponse and UploadedMedia data classes
- Add uploadMedia() and uploadMediaThumbnail() to GhostApiService
- Add uploadMediaFile(uri) to PostRepository with MIME detection
2026-03-20 00:42:58 +01:00
Paweł Orzech
807c6d559e merge: integrate Phase 2 (Tags CRUD) with existing phases 2026-03-20 00:39:31 +01:00
Paweł Orzech
7d199e9fe9 merge: integrate Phase 3 (Members API) with existing phases 2026-03-20 00:37:02 +01:00
Paweł Orzech
a81a65281f feat: add tag statistics section in Stats screen
StatsViewModel fetches tags from TagRepository, computes most used tag
and posts-without-tags count. StatsScreen shows "Tags" section with
horizontal progress bars (LinearProgressIndicator per tag, colored by
accent_color), most used tag, total tags, and posts without tags count.
2026-03-20 00:35:23 +01:00
Paweł Orzech
33647d41d6 feat: add Member detail screen with profile, subscriptions, activity, and labels
MemberDetailScreen shows scrollable profile: large avatar header with name
and email, 3 quick stat tiles (status, open rate, emails sent), subscription
details for paid members (tier, price, renewal date, cancellation status),
activity section (joined date, last seen, geolocation), newsletters list
with read-only checkboxes, labels as FlowRow of AssistChips, email activity
with open rate progress bar, and member notes.
2026-03-20 00:35:01 +01:00
Paweł Orzech
0752238578 merge: integrate Phase 1 (Site Metadata) with Phase 7 (Pages) 2026-03-20 00:34:46 +01:00
Paweł Orzech
aaf29f1512 feat: add tag filter chips in Feed with popular tags LazyRow
FeedViewModel fetches tags on refresh(), takes top 10 by post count.
FeedScreen shows LazyRow of FilterChip below status filter: "All tags"
first, then popular tags with post counts. Tapping filters posts by tag.
Post cards now show tags in compact labelSmall format joined by dots.
2026-03-20 00:33:57 +01:00
Paweł Orzech
ac461c3e6f feat: show "Publishing to" chip in Composer for multi-account users
When more than one account is configured, display an informational
AssistChip at the top of the Composer showing the active blog name
and site icon. Uses SiteMetadataCache for the blog title, falls back
to account name. Non-clickable, only shown for disambiguation.
2026-03-20 00:33:16 +01:00
Paweł Orzech
afa0005a47 feat: add Members list screen with search, filter, pagination, and nav routes
MembersViewModel manages members list state with loading, pagination,
filter (All/Free/Paid), and debounced search. MembersScreen shows
TopAppBar with total count, search field, segmented filter buttons,
and LazyColumn with member rows (avatar via Coil or colored initial,
name, email, open rate progress bar, relative time, PAID/NEW badges).
Add Routes.MEMBERS and Routes.MEMBER_DETAIL to NavGraph (not in
bottomBarRoutes). Wire "See all members" button from Stats screen.
2026-03-20 00:32:45 +01:00
Paweł Orzech
0679b18b8e feat: show blog name and site icon in Feed top bar
Replace account name with blog title from SiteMetadataCache in the Feed
TopAppBar. Show site icon (24dp, circular) before the title. Truncate
blog name to 20 characters with ellipsis. Falls back to account name
or "Swoosh" if no cached site data exists.
2026-03-20 00:32:24 +01:00
Paweł Orzech
11b20fd42a feat: add Tags management screen with list/edit modes
TagsViewModel manages tag CRUD state. TagsScreen shows searchable list
of OutlinedCards with accent dot, name, count, description. Edit mode
supports name, slug (read-only), description, accent_color hex,
visibility radio. Wired into NavGraph via Routes.TAGS and accessible
from Settings screen via "Tags" row.
2026-03-20 00:31:53 +01:00
Paweł Orzech
b829ff5963 Merge branch 'worktree-agent-a5a483ec' into claude/ghost-microblog-android-utau1 2026-03-20 00:31:46 +01:00
Paweł Orzech
471fea6183 feat: add blog info section in Settings with site metadata
Display cached Ghost site metadata (logo/icon, title, description, URL,
version, locale) in a card above the Current Account section. Add "Open
Ghost Admin" button that launches the blog's admin panel in browser.
Show version warning banner if Ghost version is older than v5.
2026-03-20 00:31:22 +01:00
Paweł Orzech
83b779155e feat: add Pages list and editor screen with Settings navigation
Add PagesViewModel with CRUD operations and edit/create state management.
Add PagesScreen with dual-mode UI (list with long-press context menu and
editor with title/content/slug/status fields). Wire navigation from
Settings via "Static Pages" row. Pages use slide-in-horizontal transition
consistent with other detail screens.
2026-03-20 00:31:22 +01:00
Paweł Orzech
e99d88e10a feat: show member stats tiles in Stats screen with animated counters
StatsViewModel now fetches members via MemberRepository and computes
MemberStats. StatsScreen shows a 2x3 grid of ElevatedCard tiles when
memberStats is available: Total, New this week, Open rate, Free, Paid,
MRR. Includes animated counters and a "See all members" navigation button.
Member fetch failure is non-fatal (tiles simply hidden).
2026-03-20 00:29:50 +01:00
Paweł Orzech
be37f6284f feat: fetch site metadata on setup and show confirmation card
After successful connection test, fetch Ghost /site/ endpoint to get
blog name, description, icon, and version. Show a confirmation card
with site details before completing setup. Warn if Ghost version < 5.
Cache site metadata per account via SiteMetadataCache. Falls back to
existing behavior if site fetch fails.
2026-03-20 00:29:09 +01:00
Paweł Orzech
532e04e571 feat: add tag autocomplete in Composer with suggestions and chips
ComposerViewModel fetches available tags from TagRepository on init,
filters suggestions as user types, supports addTag/removeTag. ComposerScreen
shows tag input field with dropdown suggestions (name + post count),
"Create new" option, and FlowRow of InputChip tags with close icons.
2026-03-20 00:28:21 +01:00
Paweł Orzech
64a573a95c feat: add MemberRepository with fetchMembers, fetchAllMembers, and getMemberStats
MemberRepository follows the same pattern as PostRepository: Context constructor,
AccountManager, ApiClient. Includes fetchMembers (paged), fetchMember (single),
fetchAllMembers (all pages, max 20), and getMemberStats (pure function computing
total/free/paid/newThisWeek/avgOpenRate/MRR). Comprehensive tests for getMemberStats.
2026-03-20 00:28:10 +01:00
Paweł Orzech
a558a2f289 feat: add PageRepository for Ghost Pages CRUD operations
Follows PostRepository pattern with AccountManager-based auth,
Dispatchers.IO coroutine context, and Result<T> return types.
Exposes fetchPages, createPage, updatePage, deletePage methods
plus getBlogUrl for constructing page URLs in the UI.
2026-03-20 00:28:01 +01:00
Paweł Orzech
d83309f8bc feat: add Pages API model, endpoints, and model tests
Introduce GhostPage, PagesResponse, PageWrapper data classes for
Ghost CMS static pages. Add CRUD endpoints (getPages, createPage,
updatePage, deletePage) to GhostApiService. Include comprehensive
unit tests for serialization and default values.
2026-03-20 00:27:33 +01:00
Paweł Orzech
492ee1ca11 feat: add SiteMetadataCache for per-account site metadata storage
SharedPreferences-based cache for GhostSite metadata keyed by account ID.
Supports save/get/getVersion/remove operations with Gson serialization.
Includes Robolectric tests for round-trip, overwrite, multi-account
isolation, and removal.
2026-03-20 00:26:46 +01:00
Paweł Orzech
689b8cc8c2 feat: add Member model, API endpoints, and model parsing tests
Add MemberModels.kt with GhostMember, MemberLabel, MemberNewsletter,
MemberSubscription, SubscriptionPrice, and SubscriptionTier data classes.
Add getMembers() and getMember() endpoints to GhostApiService.
Add comprehensive JSON parsing tests for all member model types.
2026-03-20 00:26:32 +01:00
Paweł Orzech
2dbb4ad005 feat: add TagRepository for tag CRUD operations
Follows PostRepository pattern: constructor takes Context, creates
AccountManager, uses ApiClient.getService, wraps calls in
withContext(Dispatchers.IO), returns Result<T>.
2026-03-20 00:26:15 +01:00
Paweł Orzech
d0019947f8 feat: add extended tag model (GhostTagFull) and tag CRUD API endpoints
Add TagModels.kt with GhostTagFull, TagsResponse, TagWrapper, TagCount
data classes for full Ghost tag management. Add getTags, getTag,
createTag, updateTag, deleteTag endpoints to GhostApiService.
2026-03-20 00:25:34 +01:00
Paweł Orzech
6761eae351 feat: add GhostSite model and /site/ API endpoint
Add GhostSite data class for Ghost CMS site metadata (title, description,
logo, icon, accent color, URL, version, locale). Add getSite() endpoint
to GhostApiService. Include unit tests for Gson deserialization and
version parsing.
2026-03-20 00:24:50 +01:00
Paweł Orzech
8326d06861 feat: add DB migration v3→v4 with new LocalPost columns for email, media, files 2026-03-20 00:21:28 +01:00
Paweł Orzech
0891013df6
docs: update CLAUDE.md and README.md to reflect current project state
Add multi-account, stats dashboard, search/filtering, animations,
theme system, and expanded architecture documentation.
2026-03-19 15:49:13 +01:00
63 changed files with 9478 additions and 367 deletions

View file

@ -27,9 +27,24 @@ MVVM with Repository pattern, single-module Gradle project.
- **`data/api/`** — Retrofit service (`GhostApiService`), JWT auth (`GhostJwtGenerator`, `GhostAuthInterceptor`), and `ApiClient` singleton with dynamic base URL - **`data/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). Enums: `PostStatus`, `QueueStatus` - **`data/model/`** — Three model layers: `GhostPost` (API), `LocalPost` (Room entity), `FeedPost` (UI display). Additional models: `PostStats`, `OverallStats`, `GhostAccount`, `GhostNewsletter`, `GhostPage`, `GhostMember`. Enums: `PostStatus`, `QueueStatus`, `PostFilter`, `SortOrder`
- **`data/repository/`** — `PostRepository` coordinates local DB and remote API; `OpenGraphFetcher` parses link previews via Jsoup - **`data/repository/`** — `PostRepository` coordinates local DB and remote API; `OpenGraphFetcher` parses link previews via Jsoup
- **`ui/`** — Jetpack Compose screens (Feed, Composer, Detail, Setup, Settings) with ViewModels using `StateFlow` - **`data/`** (root utilities) — `AccountManager` (multi-account, up to 5), `CredentialsManager`, `FeedPreferences`, `HashtagParser`, `MobiledocBuilder`, `ShareUtils`, `UrlNormalizer`
- **`ui/animation/`** — `SwooshMotion` shared animation specs (bouncy, snappy, gentle, quick)
- **`ui/components/`** — Reusable composables: `AnimatedDialog`, `ConfirmationDialog`, `PulsingPlaceholder`
- **`ui/feed/`** — Post feed with search, filtering (All/Published/Draft/Scheduled), sorting
- **`ui/composer/`** — Post creation/editing with image uploads, link previews, hashtags, scheduling
- **`ui/detail/`** — Full post view with pin toggle and animated delete dialog
- **`ui/members/`** — Ghost members/subscribers management
- **`ui/newsletter/`** — Newsletter configuration, subscriber count, newsletter list
- **`ui/pages/`** — Static pages CRUD (create, edit, delete, publish)
- **`ui/preview/`** — HTML post preview
- **`ui/stats/`** — Statistics dashboard with animated counters (total posts, word counts, reading time, tags)
- **`ui/settings/`** — Settings, account management, theme toggle, tags toggle, disconnect
- **`ui/setup/`** — Initial configuration wizard and add-account flow
- **`ui/tags/`** — Tag management and filtering
- **`ui/theme/`** — Material 3 theming with `ThemeMode` (Light/Dark/System), `ThemeViewModel`, `ThemePreferences`
- **`ui/navigation/`** — Compose Navigation graph with bottom nav (Home, Newsletter, Stats, Settings)
- **`worker/`** — `PostUploadWorker` (WorkManager) handles offline queue with exponential backoff - **`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.
@ -40,6 +55,10 @@ MVVM with Repository pattern, single-module Gradle project.
- **Content format:** Posts use Ghost's mobiledoc JSON format, built by `MobiledocBuilder` (supports text paragraphs and bookmark cards) - **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
@ -55,6 +74,10 @@ Version is defined in `app/build.gradle.kts` (`versionCode` and `versionName`).
- **MINOR** (0.2.0 → 0.3.0): New features, UI changes, significant improvements - **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.2.0"`, `versionCode = 2` **Current:** `versionName = "0.3.0"`, `versionCode = 3`
**Process:** When making a release commit, bump both `versionCode` (+1) and `versionName` in `app/build.gradle.kts`. Always bump version when creating a release build or PR. **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).

View file

@ -7,21 +7,35 @@ A native Android microblogging client for [Ghost CMS](https://ghost.org). Write,
[![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) [![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose)
[![License](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue)](LICENSE) [![License](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue)](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, modern interface built entirely with Jetpack Compose - **Material 3 UI** — Clean, green-tinted design with polished animations and Light/Dark/System themes
## Screenshots
> Coming soon — contributions welcome!
## Architecture ## Architecture
@ -32,21 +46,31 @@ 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) │ ├── model/ # GhostPost (API), LocalPost (DB), FeedPost (UI), PostStats, GhostAccount
│ └── repository/ # PostRepository, OpenGraphFetcher │ └── repository/ # PostRepository, OpenGraphFetcher
├── ui/ ├── ui/
│ ├── feed/ # Post list with pull-to-refresh │ ├── animation/ # SwooshMotion shared animation specs
│ ├── composer/ # Post creation and editing │ ├── components/ # Reusable composables (dialogs, placeholders)
│ ├── detail/ # Full post view │ ├── composer/ # Post creation with images, links, hashtags, scheduling
│ ├── setup/ # Initial configuration wizard │ ├── detail/ # Full post view with pin toggle
│ ├── settings/ # App settings and logout │ ├── feed/ # Post feed with search and filtering
│ ├── navigation/ # Compose Navigation graph │ ├── members/ # Ghost members/subscribers management
│ └── theme/ # Material 3 theming │ ├── newsletter/ # Newsletter configuration and subscriber info
│ ├── pages/ # Static pages CRUD
│ ├── preview/ # HTML post preview
│ ├── settings/ # App settings, account management, theme toggle
│ ├── setup/ # Configuration wizard and add-account flow
│ ├── stats/ # Statistics dashboard with animated counters
│ ├── tags/ # Tag management
│ ├── navigation/ # Compose Navigation with bottom nav bar
│ └── theme/ # Material 3 theming (Light/Dark/System)
└── worker/ # PostUploadWorker (WorkManager) └── worker/ # PostUploadWorker (WorkManager)
``` ```
**Data flow:** Compose UI &rarr; ViewModel &rarr; Repository &rarr; Room (local) + Retrofit (remote). Posts are persisted to Room first, then queued for upload via WorkManager. **Data flow:** Compose UI &rarr; ViewModel &rarr; Repository &rarr; 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
@ -66,7 +90,7 @@ com.swoosh.microblog/
### Build and run ### Build and run
```bash ```bash
git clone https://github.com/pawelorzech/Swoosh.git git clone https://github.com/nicekid1/Swoosh.git
cd Swoosh cd Swoosh
./gradlew assembleDebug ./gradlew assembleDebug
``` ```
@ -89,7 +113,7 @@ The project includes unit tests with JUnit 4 and Robolectric:
./gradlew app:testDebugUnitTest # Debug variant only ./gradlew app:testDebugUnitTest # Debug variant only
``` ```
Test coverage includes JWT generation, mobiledoc building, URL normalization, data model serialization, auth interceptors, and time formatting. 31 test classes covering JWT generation, mobiledoc building, URL normalization, data model serialization, auth interceptors, time formatting, hashtag parsing, account management, feed preferences, newsletter preferences, member models, and theme modes.
## Tech stack ## Tech stack
@ -100,6 +124,7 @@ Test coverage includes JWT generation, mobiledoc building, URL normalization, da
| 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 |

View file

@ -12,8 +12,8 @@ android {
applicationId = "com.swoosh.microblog" applicationId = "com.swoosh.microblog"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 2 versionCode = 3
versionName = "0.2.0" versionName = "0.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true } vectorDrawables { useSupportLibrary = true }
@ -107,6 +107,10 @@ 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")

View file

@ -9,59 +9,27 @@ 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, linkPreview?.url, linkPreview?.title, linkPreview?.description) return build(text, linkUrl = linkPreview?.url, linkTitle = linkPreview?.title, linkDescription = linkPreview?.description)
}
fun build(
text: String,
linkUrl: String?,
linkTitle: String?,
linkDescription: String?
): String {
return build(text, emptyList(), linkUrl, linkTitle, linkDescription, null)
} }
/** /**
* Build with a single image URL and optional alt text (HEAD's 6-param overload). * Builds mobiledoc JSON with support for multiple images (with optional alt text on the first),
* optional video, optional audio, an optional link preview, and an optional file attachment.
*
* Card order: images -> video -> audio -> bookmark -> file
*/ */
fun build( fun build(
text: String, text: String,
linkUrl: String?, imageUrls: List<String> = emptyList(),
linkTitle: String?, linkUrl: String? = null,
linkDescription: String?, linkTitle: String? = null,
imageUrl: String?, linkDescription: String? = null,
imageAlt: String? imageAlt: String? = null,
): String { videoUrl: String? = null,
val imageUrls = if (imageUrl != null) listOf(imageUrl) else emptyList() audioUrl: String? = null,
return build(text, imageUrls, linkUrl, linkTitle, linkDescription, imageAlt) fileUrl: String? = null,
} 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")
@ -77,6 +45,20 @@ 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)
@ -86,6 +68,14 @@ 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 ""

View file

@ -0,0 +1,33 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
class NewsletterPreferences private constructor(
private val prefs: SharedPreferences,
private val accountIdProvider: () -> String
) {
constructor(context: Context) : this(
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE),
accountIdProvider = { AccountManager(context).getActiveAccount()?.id ?: "" }
)
/** Constructor for testing with plain SharedPreferences and a fixed account ID. */
constructor(prefs: SharedPreferences, accountId: String) : this(
prefs = prefs,
accountIdProvider = { accountId }
)
private fun activeAccountId(): String = accountIdProvider()
fun isNewsletterEnabled(): Boolean =
prefs.getBoolean("newsletter_enabled_${activeAccountId()}", false)
fun setNewsletterEnabled(enabled: Boolean) =
prefs.edit().putBoolean("newsletter_enabled_${activeAccountId()}", enabled).apply()
companion object {
const val PREFS_NAME = "newsletter_prefs"
}
}

View file

@ -0,0 +1,42 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import com.swoosh.microblog.data.model.GhostSite
class SiteMetadataCache(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(
PREFS_NAME, Context.MODE_PRIVATE
)
private val gson = Gson()
fun save(accountId: String, site: GhostSite) {
val json = gson.toJson(site)
prefs.edit().putString(keyForAccount(accountId), json).apply()
}
fun get(accountId: String): GhostSite? {
val json = prefs.getString(keyForAccount(accountId), null) ?: return null
return try {
gson.fromJson(json, GhostSite::class.java)
} catch (e: Exception) {
null
}
}
fun getVersion(accountId: String): String? {
return get(accountId)?.version
}
fun remove(accountId: String) {
prefs.edit().remove(keyForAccount(accountId)).apply()
}
private fun keyForAccount(accountId: String): String = "site_$accountId"
companion object {
const val PREFS_NAME = "site_metadata_cache"
}
}

View file

@ -0,0 +1,32 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
class TagsPreferences private constructor(
private val prefs: SharedPreferences,
private val accountIdProvider: () -> String
) {
constructor(context: Context) : this(
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE),
accountIdProvider = { AccountManager(context).getActiveAccount()?.id ?: "" }
)
constructor(prefs: SharedPreferences, accountId: String) : this(
prefs = prefs,
accountIdProvider = { accountId }
)
private fun activeAccountId(): String = accountIdProvider()
fun isTagsEnabled(): Boolean =
prefs.getBoolean("tags_enabled_${activeAccountId()}", true)
fun setTagsEnabled(enabled: Boolean) =
prefs.edit().putBoolean("tags_enabled_${activeAccountId()}", enabled).apply()
companion object {
const val PREFS_NAME = "tags_prefs"
}
}

View file

@ -16,3 +16,10 @@ 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("/")

View file

@ -1,7 +1,16 @@
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
@ -22,14 +31,18 @@ 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}/")
@ -37,15 +50,104 @@ 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(

View file

@ -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 = 3, exportSchema = false) @Database(entities = [LocalPost::class], version = 4, exportSchema = false)
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@ -42,6 +42,25 @@ 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(
@ -49,7 +68,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"swoosh_database" "swoosh_database"
) )
.addMigrations(MIGRATION_1_3, MIGRATION_2_3) .addMigrations(MIGRATION_1_3, MIGRATION_2_3, MIGRATION_3_4)
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.build() .build()
INSTANCE = instance INSTANCE = instance

View file

@ -11,13 +11,21 @@ class Converters {
fun fromPostStatus(value: PostStatus): String = value.name fun fromPostStatus(value: PostStatus): String = value.name
@TypeConverter @TypeConverter
fun toPostStatus(value: String): PostStatus = PostStatus.valueOf(value) fun toPostStatus(value: String): PostStatus = try {
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 = QueueStatus.valueOf(value) fun toQueueStatus(value: String): QueueStatus = try {
QueueStatus.valueOf(value)
} catch (_: Exception) {
QueueStatus.NONE
}
companion object { companion object {
private val gson = Gson() private val gson = Gson()

View file

@ -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) statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED, QueueStatus.QUEUED_EMAIL_ONLY)
): 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) statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED, QueueStatus.QUEUED_EMAIL_ONLY)
): List<LocalPost> ): List<LocalPost>
@Query("SELECT * FROM local_posts WHERE localId = :localId") @Query("SELECT * FROM local_posts WHERE localId = :localId")

View file

@ -0,0 +1,10 @@
package com.swoosh.microblog.data.model
data class FileUploadResponse(
val files: List<UploadedFile>
)
data class UploadedFile(
val url: String,
val ref: String?
)

View file

@ -48,7 +48,8 @@ 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(
@ -89,19 +90,33 @@ 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
} }
@ -120,6 +135,8 @@ 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?,
@ -131,7 +148,10 @@ 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
@ -148,7 +168,8 @@ 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) {
@ -156,6 +177,7 @@ 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. */
@ -164,6 +186,7 @@ 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"
} }
} }

View file

@ -0,0 +1,11 @@
package com.swoosh.microblog.data.model
data class MediaUploadResponse(
val media: List<UploadedMedia>
)
data class UploadedMedia(
val url: String,
val ref: String?,
val fileName: String?
)

View file

@ -0,0 +1,35 @@
package com.swoosh.microblog.data.model
data class MembersResponse(
val members: List<GhostMember>,
val meta: Meta?
)
data class GhostMember(
val id: String,
val email: String?,
val name: String?,
val status: String?, // "free" or "paid"
val avatar_image: String?,
val email_count: Int?,
val email_opened_count: Int?,
val email_open_rate: Double?,
val last_seen_at: String?,
val created_at: String?,
val updated_at: String?,
val labels: List<MemberLabel>?,
val newsletters: List<MemberNewsletter>?,
val subscriptions: List<MemberSubscription>?,
val note: String?,
val geolocation: String?
)
data class MemberLabel(val id: String?, val name: String, val slug: String?)
data class MemberNewsletter(val id: String, val name: String?, val slug: String?)
data class MemberSubscription(
val id: String?, val status: String?, val start_date: String?,
val current_period_end: String?, val cancel_at_period_end: Boolean?,
val price: SubscriptionPrice?, val tier: SubscriptionTier?
)
data class SubscriptionPrice(val amount: Int?, val currency: String?, val interval: String?)
data class SubscriptionTier(val id: String?, val name: String?)

View file

@ -0,0 +1,21 @@
package com.swoosh.microblog.data.model
data class NewslettersResponse(
val newsletters: List<GhostNewsletter>
)
data class GhostNewsletter(
val id: String,
val uuid: String?,
val name: String,
val slug: String,
val description: String?,
val status: String?,
val visibility: String?,
val subscribe_on_signup: Boolean?,
val sort_order: Int?,
val sender_name: String?,
val sender_email: String?,
val created_at: String?,
val updated_at: String?
)

View file

@ -38,6 +38,7 @@ 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
} }
} }
@ -47,6 +48,7 @@ data class OverallStats(
"published" -> publishedCount++ "published" -> publishedCount++
"draft" -> draftCount++ "draft" -> draftCount++
"scheduled" -> scheduledCount++ "scheduled" -> scheduledCount++
"sent" -> publishedCount++
} }
} }

View file

@ -0,0 +1,26 @@
package com.swoosh.microblog.data.model
data class PagesResponse(
val pages: List<GhostPage>,
val meta: Meta?
)
data class PageWrapper(
val pages: List<GhostPage>
)
data class GhostPage(
val id: String? = null,
val title: String? = null,
val slug: String? = null,
val url: String? = null,
val html: String? = null,
val plaintext: String? = null,
val mobiledoc: String? = null,
val status: String? = null,
val feature_image: String? = null,
val custom_excerpt: String? = null,
val created_at: String? = null,
val updated_at: String? = null,
val published_at: String? = null
)

View file

@ -0,0 +1,12 @@
package com.swoosh.microblog.data.model
data class GhostSite(
val title: String?,
val description: String?,
val logo: String?,
val icon: String?,
val accent_color: String?,
val url: String?,
val version: String?,
val locale: String?
)

View file

@ -0,0 +1,26 @@
package com.swoosh.microblog.data.model
data class TagsResponse(
val tags: List<GhostTagFull>,
val meta: Meta?
)
data class TagWrapper(
val tags: List<GhostTagFull>
)
data class GhostTagFull(
val id: String? = null,
val name: String,
val slug: String? = null,
val description: String? = null,
val feature_image: String? = null,
val visibility: String? = "public",
val accent_color: String? = null,
val count: TagCount? = null,
val created_at: String? = null,
val updated_at: String? = null,
val url: String? = null
)
data class TagCount(val posts: Int?)

View file

@ -0,0 +1,139 @@
package com.swoosh.microblog.data.repository
import android.content.Context
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.api.GhostApiService
import com.swoosh.microblog.data.model.GhostMember
import com.swoosh.microblog.data.model.MembersResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.temporal.ChronoUnit
class MemberRepository(private val context: Context) {
private val accountManager = AccountManager(context)
private fun getApi(): GhostApiService {
val account = accountManager.getActiveAccount()
?: throw IllegalStateException("No active account configured")
return ApiClient.getService(account.blogUrl) { account.apiKey }
}
suspend fun fetchMembers(
page: Int = 1,
limit: Int = 15,
filter: String? = null
): Result<MembersResponse> = withContext(Dispatchers.IO) {
try {
val response = getApi().getMembers(
limit = limit,
page = page,
filter = filter
)
if (response.isSuccessful) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun fetchMember(id: String): Result<GhostMember> = withContext(Dispatchers.IO) {
try {
val response = getApi().getMember(id)
if (response.isSuccessful) {
val members = response.body()!!.members
if (members.isNotEmpty()) {
Result.success(members.first())
} else {
Result.failure(Exception("Member not found"))
}
} else {
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun fetchAllMembers(): Result<List<GhostMember>> = withContext(Dispatchers.IO) {
try {
val allMembers = mutableListOf<GhostMember>()
var page = 1
var hasMore = true
while (hasMore && page <= 20) {
val response = getApi().getMembers(limit = 50, page = page)
if (response.isSuccessful) {
val body = response.body()!!
allMembers.addAll(body.members)
hasMore = body.meta?.pagination?.next != null
page++
} else {
return@withContext Result.failure(
Exception("API error ${response.code()}: ${response.errorBody()?.string()}")
)
}
}
Result.success(allMembers)
} catch (e: Exception) {
Result.failure(e)
}
}
fun getMemberStats(members: List<GhostMember>): MemberStats {
val total = members.size
val free = members.count { it.status == "free" }
val paid = members.count { it.status == "paid" }
val oneWeekAgo = Instant.now().minus(7, ChronoUnit.DAYS)
val newThisWeek = members.count { member ->
member.created_at?.let {
try {
Instant.parse(it).isAfter(oneWeekAgo)
} catch (e: Exception) {
false
}
} ?: false
}
val openRates = members.mapNotNull { it.email_open_rate }
val avgOpenRate = if (openRates.isNotEmpty()) openRates.average() else null
// Calculate MRR from paid member subscriptions
val mrr = members.filter { it.status == "paid" }.sumOf { member ->
member.subscriptions?.sumOf { sub ->
if (sub.status == "active") {
val amount = sub.price?.amount ?: 0
when (sub.price?.interval) {
"year" -> amount / 12
"month" -> amount
else -> amount
}
} else 0
} ?: 0
}
return MemberStats(
total = total,
free = free,
paid = paid,
newThisWeek = newThisWeek,
avgOpenRate = avgOpenRate,
mrr = mrr
)
}
}
data class MemberStats(
val total: Int,
val free: Int,
val paid: Int,
val newThisWeek: Int,
val avgOpenRate: Double?,
val mrr: Int
)

View file

@ -0,0 +1,81 @@
package com.swoosh.microblog.data.repository
import android.content.Context
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.api.GhostApiService
import com.swoosh.microblog.data.model.GhostPage
import com.swoosh.microblog.data.model.PageWrapper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class PageRepository(private val context: Context) {
private val accountManager = AccountManager(context)
private fun getApi(): GhostApiService {
val account = accountManager.getActiveAccount()
?: throw IllegalStateException("No active account configured")
return ApiClient.getService(account.blogUrl) { account.apiKey }
}
fun getBlogUrl(): String? {
return accountManager.getActiveAccount()?.blogUrl
}
suspend fun fetchPages(): Result<List<GhostPage>> =
withContext(Dispatchers.IO) {
try {
val response = getApi().getPages()
if (response.isSuccessful) {
Result.success(response.body()!!.pages)
} else {
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun createPage(page: GhostPage): Result<GhostPage> =
withContext(Dispatchers.IO) {
try {
val response = getApi().createPage(PageWrapper(listOf(page)))
if (response.isSuccessful) {
Result.success(response.body()!!.pages.first())
} else {
Result.failure(Exception("Create failed ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun updatePage(id: String, page: GhostPage): Result<GhostPage> =
withContext(Dispatchers.IO) {
try {
val response = getApi().updatePage(id, PageWrapper(listOf(page)))
if (response.isSuccessful) {
Result.success(response.body()!!.pages.first())
} else {
Result.failure(Exception("Update failed ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun deletePage(id: String): Result<Unit> =
withContext(Dispatchers.IO) {
try {
val response = getApi().deletePage(id)
if (response.isSuccessful) {
Result.success(Unit)
} else {
Result.failure(Exception("Delete failed ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View file

@ -8,6 +8,7 @@ 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
@ -68,10 +69,18 @@ class PostRepository(private val context: Context) {
} }
} }
suspend fun createPost(post: GhostPost): Result<GhostPost> = suspend fun createPost(
post: GhostPost,
newsletter: String? = null,
emailSegment: String? = null
): Result<GhostPost> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val response = getApi().createPost(PostWrapper(listOf(post))) val response = getApi().createPost(
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 {
@ -82,10 +91,20 @@ class PostRepository(private val context: Context) {
} }
} }
suspend fun updatePost(id: String, post: GhostPost): Result<GhostPost> = suspend fun updatePost(
id: String,
post: GhostPost,
newsletter: String? = null,
emailSegment: String? = null
): Result<GhostPost> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val response = getApi().updatePost(id, PostWrapper(listOf(post))) val response = getApi().updatePost(
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 {
@ -96,6 +115,35 @@ class PostRepository(private val context: Context) {
} }
} }
suspend fun fetchNewsletters(): Result<List<GhostNewsletter>> =
withContext(Dispatchers.IO) {
try {
val response = getApi().getNewsletters()
if (response.isSuccessful) {
Result.success(response.body()!!.newsletters)
} else {
Result.failure(Exception("Newsletters fetch failed ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun fetchSubscriberCount(): Result<Int> =
withContext(Dispatchers.IO) {
try {
val response = getApi().getMembers(limit = 1)
if (response.isSuccessful) {
val total = response.body()!!.meta?.pagination?.total ?: 0
Result.success(total)
} else {
Result.failure(Exception("Member count fetch failed ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun deletePost(id: String): Result<Unit> = suspend fun deletePost(id: String): Result<Unit> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
@ -113,8 +161,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())
@ -133,6 +181,28 @@ class PostRepository(private val context: Context) {
} }
} }
suspend fun uploadFile(uri: Uri): Result<String> =
withContext(Dispatchers.IO) {
try {
val file = copyUriToTempFile(uri)
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
val requestBody = file.asRequestBody(mimeType.toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
val response = getApi().uploadFile(part)
file.delete()
if (response.isSuccessful) {
val url = response.body()!!.files.first().url
Result.success(url)
} else {
Result.failure(Exception("File upload failed ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
/** /**
* Uploads multiple images and returns all uploaded URLs. * 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.
@ -156,10 +226,41 @@ class PostRepository(private val context: Context) {
} }
} }
private fun copyUriToTempFile(uri: Uri): File { /**
* Uploads a media file (video or audio) to the Ghost media upload endpoint.
* Determines MIME type from the content resolver. Returns the uploaded URL on success.
*/
suspend fun uploadMediaFile(uri: Uri): Result<String> =
withContext(Dispatchers.IO) {
try {
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
val extension = when {
mimeType.startsWith("video/") -> mimeType.substringAfter("video/").let { ".$it" }
mimeType.startsWith("audio/") -> mimeType.substringAfter("audio/").let { ".$it" }
else -> ""
}
val file = copyUriToTempFile(uri, extension)
val requestBody = file.asRequestBody(mimeType.toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
val response = getApi().uploadMedia(part)
file.delete()
if (response.isSuccessful) {
val url = response.body()!!.media.first().url
Result.success(url)
} else {
Result.failure(Exception("Media upload failed ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
private fun copyUriToTempFile(uri: Uri, extension: String = ".jpg"): File {
val inputStream = context.contentResolver.openInputStream(uri) val inputStream = context.contentResolver.openInputStream(uri)
?: throw IllegalStateException("Cannot open URI") ?: throw IllegalStateException("Cannot open URI")
val tempFile = File.createTempFile("upload_", ".jpg", context.cacheDir) val tempFile = File.createTempFile("upload_", extension, context.cacheDir)
FileOutputStream(tempFile).use { output -> FileOutputStream(tempFile).use { output ->
inputStream.copyTo(output) inputStream.copyTo(output)
} }

View file

@ -0,0 +1,86 @@
package com.swoosh.microblog.data.repository
import android.content.Context
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.api.GhostApiService
import com.swoosh.microblog.data.model.GhostTagFull
import com.swoosh.microblog.data.model.TagWrapper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class TagRepository(private val context: Context) {
private val accountManager = AccountManager(context)
private fun getApi(): GhostApiService {
val account = accountManager.getActiveAccount()
?: throw IllegalStateException("No active account configured")
return ApiClient.getService(account.blogUrl) { account.apiKey }
}
suspend fun fetchTags(): Result<List<GhostTagFull>> =
withContext(Dispatchers.IO) {
try {
val response = getApi().getTags()
if (response.isSuccessful) {
Result.success(response.body()!!.tags)
} else {
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun createTag(
name: String,
description: String? = null,
accentColor: String? = null
): Result<GhostTagFull> =
withContext(Dispatchers.IO) {
try {
val tag = GhostTagFull(
name = name,
description = description,
accent_color = accentColor
)
val response = getApi().createTag(TagWrapper(listOf(tag)))
if (response.isSuccessful) {
Result.success(response.body()!!.tags.first())
} else {
Result.failure(Exception("Create tag failed ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun updateTag(id: String, tag: GhostTagFull): Result<GhostTagFull> =
withContext(Dispatchers.IO) {
try {
val response = getApi().updateTag(id, TagWrapper(listOf(tag)))
if (response.isSuccessful) {
Result.success(response.body()!!.tags.first())
} else {
Result.failure(Exception("Update tag failed ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun deleteTag(id: String): Result<Unit> =
withContext(Dispatchers.IO) {
try {
val response = getApi().deleteTag(id)
if (response.isSuccessful) {
Result.success(Unit)
} else {
Result.failure(Exception("Delete tag failed ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View file

@ -0,0 +1,46 @@
package com.swoosh.microblog.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Returns an appropriate icon tint color for a file based on MIME type or file name.
* Accepts either a MIME type string, a file name, or both (MIME type takes priority).
*/
@Composable
fun fileTypeColor(mimeType: String? = null, fileName: String? = null): Color {
// Try MIME type first
if (mimeType != null) {
val color = colorForMimeType(mimeType)
if (color != null) return color
}
// Fall back to file extension
if (fileName != null) {
val color = colorForFileName(fileName)
if (color != null) return color
}
return MaterialTheme.colorScheme.onSurfaceVariant
}
private fun colorForMimeType(mimeType: String): Color? = when {
mimeType.contains("pdf") -> Color(0xFFD32F2F)
mimeType.contains("word") || mimeType.contains("doc") -> Color(0xFF1565C0)
mimeType.contains("text") -> Color(0xFF757575)
mimeType.contains("spreadsheet") || mimeType.contains("excel") -> Color(0xFF2E7D32)
mimeType.contains("presentation") || mimeType.contains("powerpoint") -> Color(0xFFE65100)
else -> null
}
private fun colorForFileName(fileName: String): Color? {
val lower = fileName.lowercase()
return when {
lower.endsWith(".pdf") -> Color(0xFFD32F2F)
lower.endsWith(".doc") || lower.endsWith(".docx") -> Color(0xFF1565C0)
lower.endsWith(".txt") || lower.endsWith(".csv") -> Color(0xFF757575)
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> Color(0xFF2E7D32)
lower.endsWith(".ppt") || lower.endsWith(".pptx") -> Color(0xFFE65100)
lower.endsWith(".zip") || lower.endsWith(".rar") || lower.endsWith(".gz") -> Color(0xFF6A1B9A)
else -> null
}
}

View file

@ -0,0 +1,248 @@
package com.swoosh.microblog.ui.components
import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
/**
* Video player composable using ExoPlayer.
* Shows a play button overlay; tap to play/pause.
* Media is only prepared (buffered) on first play tap to avoid unnecessary network usage.
*/
@Composable
fun VideoPlayer(
url: String,
modifier: Modifier = Modifier,
compact: Boolean = false
) {
val context = LocalContext.current
var isPlaying by remember { mutableStateOf(false) }
var showOverlay by remember { mutableStateOf(true) }
var hasPrepared by remember { mutableStateOf(false) }
val exoPlayer = remember(url) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url))
playWhenReady = false
}
}
DisposableEffect(exoPlayer) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
if (!playing && exoPlayer.playbackState == Player.STATE_ENDED) {
showOverlay = true
}
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
exoPlayer.release()
}
}
val height = if (compact) 180.dp else 240.dp
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.clip(MaterialTheme.shapes.medium)
.clickable {
if (isPlaying) {
exoPlayer.pause()
showOverlay = true
} else {
if (!hasPrepared) {
exoPlayer.prepare()
hasPrepared = true
}
exoPlayer.play()
showOverlay = false
}
},
contentAlignment = Alignment.Center
) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
useController = false
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
},
modifier = Modifier.fillMaxSize()
)
// Play button overlay
if (showOverlay || !isPlaying) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
}
}
}
}
/**
* Compact audio player with play/pause button, progress slider, and duration text.
* Media is only prepared (buffered) on first play tap to avoid unnecessary network usage.
*/
@Composable
fun AudioPlayer(
url: String,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
var isPlaying by remember { mutableStateOf(false) }
var currentPosition by remember { mutableLongStateOf(0L) }
var duration by remember { mutableLongStateOf(0L) }
var hasPrepared by remember { mutableStateOf(false) }
val exoPlayer = remember(url) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url))
playWhenReady = false
}
}
DisposableEffect(exoPlayer) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY) {
duration = exoPlayer.duration.coerceAtLeast(0L)
}
if (playbackState == Player.STATE_ENDED) {
isPlaying = false
exoPlayer.seekTo(0)
exoPlayer.pause()
}
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
exoPlayer.release()
}
}
// Update position periodically while playing
LaunchedEffect(isPlaying) {
while (isPlaying) {
currentPosition = exoPlayer.currentPosition.coerceAtLeast(0L)
kotlinx.coroutines.delay(500)
}
}
OutlinedCard(
modifier = modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Music note icon
Icon(
imageVector = Icons.Default.MusicNote,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
// Play/Pause button
IconButton(
onClick = {
if (isPlaying) {
exoPlayer.pause()
} else {
if (!hasPrepared) {
exoPlayer.prepare()
hasPrepared = true
}
exoPlayer.play()
}
},
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.primary
)
}
// Progress slider
Slider(
value = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f,
onValueChange = { fraction ->
val newPosition = (fraction * duration).toLong()
exoPlayer.seekTo(newPosition)
currentPosition = newPosition
},
modifier = Modifier.weight(1f),
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary
)
)
Spacer(modifier = Modifier.width(8.dp))
// Duration text
Text(
text = formatDuration(if (isPlaying) currentPosition else duration),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
private fun formatDuration(millis: Long): String {
if (millis <= 0) return "0:00"
val totalSeconds = millis / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
}

View file

@ -14,6 +14,8 @@ 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
@ -36,15 +38,22 @@ 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
@ -105,8 +114,31 @@ fun ComposerScreen(
} }
} }
// File picker
val filePickerLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
if (uri != null) {
viewModel.addFile(uri)
}
}
// Video picker
val videoPickerLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let { viewModel.setVideo(it) }
}
// Audio picker
val audioPickerLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let { viewModel.setAudio(it) }
}
val canSubmit by remember { val canSubmit by remember {
derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty()) } derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty() || state.fileUri != null || state.videoUri != null || state.audioUri != null) }
} }
Scaffold( Scaffold(
@ -143,15 +175,23 @@ 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 = MaterialTheme.colorScheme.primary, containerColor = if (isNewsletterPublish)
contentColor = MaterialTheme.colorScheme.onPrimary MaterialTheme.colorScheme.tertiaryContainer
else MaterialTheme.colorScheme.primary,
contentColor = if (isNewsletterPublish)
MaterialTheme.colorScheme.onTertiaryContainer
else MaterialTheme.colorScheme.onPrimary
) )
) { ) {
Icon(Icons.Default.Send, "Publish") Icon(
if (isNewsletterPublish) Icons.Default.Email else Icons.Default.Send,
if (isNewsletterPublish) "Publish & Send Email" else "Publish"
)
} }
} }
@ -171,13 +211,24 @@ fun ComposerScreen(
expanded = showSendMenu, 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(if (state.isEditing) "Update & Publish" else "Publish Now") }, text = { Text(publishLabel) },
onClick = { onClick = {
showSendMenu = false showSendMenu = false
viewModel.publish() viewModel.publish()
}, },
leadingIcon = { Icon(Icons.Default.Send, null) }, leadingIcon = {
Icon(
if (state.sendAsNewsletter && state.selectedNewsletter != null)
Icons.Default.Email else Icons.Default.Send,
null
)
},
enabled = canSubmit enabled = canSubmit
) )
DropdownMenuItem( DropdownMenuItem(
@ -198,6 +249,31 @@ 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
)
}
} }
} }
} }
@ -209,6 +285,39 @@ 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) {
@ -323,48 +432,17 @@ fun ComposerScreen(
} }
) )
// Extracted tags preview chips // Tags section: input + suggestions + chips (only when tags enabled)
AnimatedVisibility( if (viewModel.isTagsEnabled()) {
visible = state.extractedTags.isNotEmpty(), Spacer(modifier = Modifier.height(12.dp))
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()), TagsSection(
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy()) tagInput = state.tagInput,
) { onTagInputChange = viewModel::updateTagInput,
Column { tagSuggestions = state.tagSuggestions,
Spacer(modifier = Modifier.height(8.dp)) extractedTags = state.extractedTags,
Row( onAddTag = viewModel::addTag,
modifier = Modifier.fillMaxWidth(), onRemoveTag = viewModel::removeTag
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))
@ -376,9 +454,21 @@ 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
@ -428,6 +518,57 @@ fun ComposerScreen(
} }
} }
// Video preview card
AnimatedVisibility(
visible = state.videoUri != null,
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
) {
Column {
Spacer(modifier = Modifier.height(12.dp))
MediaPreviewCard(
icon = Icons.Default.Videocam,
label = "Video attached",
uri = state.videoUri,
onRemove = viewModel::removeVideo
)
}
}
// Audio preview card
AnimatedVisibility(
visible = state.audioUri != null,
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
) {
Column {
Spacer(modifier = Modifier.height(12.dp))
MediaPreviewCard(
icon = Icons.Default.MusicNote,
label = "Audio attached",
uri = state.audioUri,
onRemove = viewModel::removeAudio
)
}
}
// File attachment card
AnimatedVisibility(
visible = state.fileUri != null,
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
) {
Column {
Spacer(modifier = Modifier.height(12.dp))
FileAttachmentComposerCard(
fileName = state.fileName ?: "file",
fileSize = state.fileSize ?: 0,
fileMimeType = state.fileMimeType,
onRemove = viewModel::removeFile
)
}
}
// Link preview // Link preview
AnimatedVisibility( AnimatedVisibility(
visible = state.isLoadingLink, visible = state.isLoadingLink,
@ -685,6 +826,384 @@ fun ComposerScreen(
} }
} }
} }
// Newsletter confirmation dialog
val confirmNewsletter = state.selectedNewsletter
if (state.showNewsletterConfirmation && confirmNewsletter != null) {
NewsletterConfirmationDialog(
newsletterName = confirmNewsletter.name,
emailSegment = state.emailSegment,
subscriberCount = state.subscriberCount,
postTitle = state.text.take(60),
onConfirm = viewModel::confirmNewsletterSend,
onDismiss = viewModel::cancelNewsletterConfirmation
)
}
// Email-only confirmation dialog
if (state.showEmailOnlyConfirmation) {
EmailOnlyConfirmationDialog(
postPreview = state.text.take(80),
availableNewsletters = state.availableNewsletters,
selectedNewsletter = state.selectedNewsletter,
onSelectNewsletter = viewModel::selectNewsletter,
onConfirm = viewModel::confirmEmailOnly,
onDismiss = viewModel::cancelEmailOnly
)
}
}
/**
* Newsletter options section in the publish dropdown menu.
*/
@Composable
fun NewsletterDropdownSection(
state: ComposerUiState,
onToggleSendAsNewsletter: () -> Unit,
onSelectNewsletter: (GhostNewsletter) -> Unit,
onSetEmailSegment: (String) -> Unit
) {
// Send as newsletter switch
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggleSendAsNewsletter)
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Send as newsletter",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Switch(
checked = state.sendAsNewsletter,
onCheckedChange = { onToggleSendAsNewsletter() },
modifier = Modifier.padding(start = 8.dp)
)
}
// Newsletter picker and segment (only when sending)
AnimatedVisibility(
visible = state.sendAsNewsletter,
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
) {
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
// Newsletter picker
if (state.availableNewsletters.size > 1) {
Text(
text = "Newsletter:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
Column(modifier = Modifier.selectableGroup()) {
state.availableNewsletters.forEach { newsletter ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = state.selectedNewsletter?.id == newsletter.id,
onClick = { onSelectNewsletter(newsletter) }
)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = state.selectedNewsletter?.id == newsletter.id,
onClick = { onSelectNewsletter(newsletter) }
)
Text(
text = newsletter.name,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
} else if (state.availableNewsletters.size == 1) {
Text(
text = "Newsletter: ${state.availableNewsletters.first().name}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp)
)
}
// Segment picker
Text(
text = "Send to:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp)
)
val segments = listOf("all" to "All subscribers", "status:free" to "Free members", "status:-free" to "Paid members")
Column(modifier = Modifier.selectableGroup()) {
segments.forEach { (value, label) ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = state.emailSegment == value,
onClick = { onSetEmailSegment(value) }
)
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = state.emailSegment == value,
onClick = { onSetEmailSegment(value) }
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
// Warning text
val countText = state.subscriberCount?.let { "~$it" } ?: "your"
Text(
text = "\u26A0 Email will be sent to $countText subscribers. This cannot be undone.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
/**
* Confirmation dialog for newsletter sending.
* Requires typing "WYSLIJ" to confirm.
*/
@Composable
fun NewsletterConfirmationDialog(
newsletterName: String,
emailSegment: String,
subscriberCount: Int?,
postTitle: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
var confirmInput by remember { mutableStateOf("") }
val isConfirmEnabled = confirmInput == "WYSLIJ"
val segmentLabel = when (emailSegment) {
"all" -> "All subscribers"
"status:free" -> "Free members"
"status:-free" -> "Paid members"
else -> emailSegment
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "Confirm Newsletter Send",
style = MaterialTheme.typography.titleMedium
)
},
text = {
Column {
Text(
text = "You are about to send an email newsletter:",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(12.dp))
// Summary card
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "Newsletter: $newsletterName",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold
)
Text(
text = "Segment: $segmentLabel",
style = MaterialTheme.typography.bodySmall
)
if (subscriberCount != null) {
Text(
text = "Recipients: ~$subscriberCount",
style = MaterialTheme.typography.bodySmall
)
}
Text(
text = "Post: ${postTitle.take(40)}${if (postTitle.length > 40) "..." else ""}",
style = MaterialTheme.typography.bodySmall
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Warning
Text(
text = "\u26A0 IRREVERSIBLE: Once sent, this email cannot be recalled.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
// Confirmation input
Text(
text = "Type WYSLIJ to confirm:",
style = MaterialTheme.typography.labelMedium
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = confirmInput,
onValueChange = { confirmInput = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("WYSLIJ") }
)
}
},
confirmButton = {
Button(
onClick = onConfirm,
enabled = isConfirmEnabled,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Icon(
Icons.Default.Email,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Send Email")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
/**
* Confirmation dialog for email-only sending.
* Shows a warning that the post will NOT appear on the blog.
*/
@Composable
fun EmailOnlyConfirmationDialog(
postPreview: String,
availableNewsletters: List<GhostNewsletter>,
selectedNewsletter: GhostNewsletter?,
onSelectNewsletter: (GhostNewsletter) -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(32.dp)
)
},
title = {
Text(
text = "Send via email only?",
style = MaterialTheme.typography.titleMedium
)
},
text = {
Column {
// Post content preview
if (postPreview.isNotBlank()) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Text(
text = postPreview + if (postPreview.length >= 80) "..." else "",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(12.dp))
}
// Newsletter picker (only if multiple)
if (availableNewsletters.size > 1) {
Text(
text = "Newsletter:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Column(modifier = Modifier.selectableGroup()) {
availableNewsletters.forEach { newsletter ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selectedNewsletter?.id == newsletter.id,
onClick = { onSelectNewsletter(newsletter) }
)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedNewsletter?.id == newsletter.id,
onClick = { onSelectNewsletter(newsletter) }
)
Text(
text = newsletter.name,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
} else if (selectedNewsletter != null) {
Text(
text = "Newsletter: ${selectedNewsletter.name}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
}
// Bold warning
Text(
text = "This cannot be undone. Post will NOT appear on blog.",
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.error
)
}
},
confirmButton = {
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Text("\u2709 SEND EMAIL")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
} }
/** /**
@ -878,3 +1397,276 @@ class HashtagVisualTransformation(private val hashtagColor: Color) : VisualTrans
return TransformedText(annotated, OffsetMapping.Identity) 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)
)
}
}
}
}

View file

@ -6,11 +6,14 @@ 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
@ -25,6 +28,9 @@ 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())
@ -36,6 +42,157 @@ 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
@ -54,6 +211,8 @@ 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,
@ -113,6 +272,73 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
} }
} }
/**
* Attach a file to the post. Reads filename and size from ContentResolver.
* Validates type and size (max 50 MB).
*/
fun addFile(uri: Uri) {
val contentResolver = appContext.contentResolver
var name: String? = null
var size: Long? = null
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
if (nameIndex >= 0) name = cursor.getString(nameIndex)
if (sizeIndex >= 0) size = cursor.getLong(sizeIndex)
}
}
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
// Validate size: max 50 MB
val maxSize = 50L * 1024 * 1024
if (size != null && size!! > maxSize) {
_uiState.update { it.copy(error = "File too large. Maximum file size is 50 MB.") }
return
}
_uiState.update {
it.copy(
fileUri = uri,
fileName = name ?: "file",
fileSize = size,
fileMimeType = mimeType,
uploadedFileUrl = null,
error = null
)
}
}
fun removeFile() {
_uiState.update {
it.copy(
fileUri = null,
fileName = null,
fileSize = null,
fileMimeType = null,
uploadedFileUrl = null
)
}
}
fun setVideo(uri: Uri) {
_uiState.update { it.copy(videoUri = uri) }
}
fun removeVideo() {
_uiState.update { it.copy(videoUri = null, uploadedVideoUrl = null) }
}
fun setAudio(uri: Uri) {
_uiState.update { it.copy(audioUri = uri) }
}
fun removeAudio() {
_uiState.update { it.copy(audioUri = null, uploadedAudioUrl = null) }
}
fun fetchLinkPreview(url: String) { fun fetchLinkPreview(url: String) {
if (url.isBlank()) return if (url.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
@ -180,7 +406,15 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
_uiState.update { it.copy(featured = !it.featured) } _uiState.update { it.copy(featured = !it.featured) }
} }
fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH) fun publish() {
val state = _uiState.value
if (state.sendAsNewsletter && state.selectedNewsletter != null && !state.showNewsletterConfirmation) {
// Show confirmation dialog before sending as newsletter
_uiState.update { it.copy(showNewsletterConfirmation = true) }
return
}
submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH)
}
fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE) fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE)
@ -191,14 +425,16 @@ 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()) return if (state.text.isBlank() && state.imageUris.isEmpty() && state.fileUri == null && state.videoUri == null && state.audioUri == null) 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)
val extractedTags = HashtagParser.parse(state.text) // Merge hashtag-parsed tags with manually-added tags (deduplicated)
val tagsJson = Gson().toJson(extractedTags) 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 altText = state.imageAlt.ifBlank { null }
@ -214,13 +450,18 @@ 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)
@ -245,14 +486,66 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
) )
} }
// Upload video if present
var videoUrl: String? = state.uploadedVideoUrl
if (videoUrl == null && state.videoUri != null) {
_uiState.update { it.copy(isUploadingMedia = true) }
val videoResult = repository.uploadMediaFile(state.videoUri)
videoResult.fold(
onSuccess = { url -> videoUrl = url },
onFailure = { e ->
_uiState.update { it.copy(isSubmitting = false, isUploadingMedia = false, error = "Video upload failed: ${e.message}") }
return@launch
}
)
}
// Upload audio if present
var audioUrl: String? = state.uploadedAudioUrl
if (audioUrl == null && state.audioUri != null) {
_uiState.update { it.copy(isUploadingMedia = true) }
val audioResult = repository.uploadMediaFile(state.audioUri)
audioResult.fold(
onSuccess = { url -> audioUrl = url },
onFailure = { e ->
_uiState.update { it.copy(isSubmitting = false, isUploadingMedia = false, error = "Audio upload failed: ${e.message}") }
return@launch
}
)
}
_uiState.update { it.copy(isUploadingMedia = false) }
// Upload file if attached
var uploadedFileUrl = state.uploadedFileUrl
if (state.fileUri != null && uploadedFileUrl == null) {
val fileResult = repository.uploadFile(state.fileUri)
fileResult.fold(
onSuccess = { url -> uploadedFileUrl = url },
onFailure = { e ->
_uiState.update { it.copy(isSubmitting = false, error = "File upload failed: ${e.message}") }
return@launch
}
)
}
val featureImage = uploadedImageUrls.firstOrNull() val featureImage = uploadedImageUrls.firstOrNull()
val mobiledoc = MobiledocBuilder.build( val mobiledoc = MobiledocBuilder.build(
state.text, uploadedImageUrls, text = state.text,
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description, imageUrls = uploadedImageUrls,
altText linkUrl = state.linkPreview?.url,
linkTitle = state.linkPreview?.title,
linkDescription = state.linkPreview?.description,
imageAlt = altText,
videoUrl = videoUrl,
audioUrl = audioUrl,
fileUrl = uploadedFileUrl,
fileName = state.fileName,
fileSize = state.fileSize ?: 0
) )
val ghostTags = extractedTags.map { GhostTag(name = it) } val ghostTags = allTags.map { GhostTag(name = it) }
val ghostPost = GhostPost( val ghostPost = GhostPost(
title = title, title = title,
@ -266,11 +559,17 @@ 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) repository.updatePost(editingGhostId!!, updatePost, newsletter = newsletterSlug, emailSegment = emailSeg)
} else { } else {
repository.createPost(ghostPost) repository.createPost(ghostPost, newsletter = newsletterSlug, emailSegment = emailSeg)
} }
result.fold( result.fold(
@ -291,13 +590,21 @@ 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)
@ -324,17 +631,41 @@ 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.

View file

@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.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
@ -53,7 +54,10 @@ 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
@ -94,7 +98,7 @@ fun DetailScreen(
} }
// D1: Content reveal sequence // D1: Content reveal sequence
val revealCount = 6 // status, text, tags, gallery, link, stats val revealCount = 8 // status, text, tags, gallery, video, audio, link, stats
val sectionVisible = remember { List(revealCount) { mutableStateOf(false) } } val sectionVisible = remember { List(revealCount) { mutableStateOf(false) } }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
sectionVisible.forEachIndexed { index, state -> sectionVisible.forEachIndexed { index, state ->
@ -300,9 +304,31 @@ fun DetailScreen(
} }
} }
// Section 4 — Link preview // Section 4 — Video player
AnimatedVisibility( AnimatedVisibility(
visible = sectionVisible[4].value && post.linkUrl != null, visible = sectionVisible[4].value && post.videoUrl != null,
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
VideoPlayer(url = post.videoUrl!!, compact = false)
}
}
// Section 5 — Audio player
AnimatedVisibility(
visible = sectionVisible[5].value && post.audioUrl != null,
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
AudioPlayer(url = post.audioUrl!!)
}
}
// Section 6 — Link preview
AnimatedVisibility(
visible = sectionVisible[6].value && post.linkUrl != null,
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle()) enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
) { ) {
Column { Column {
@ -348,9 +374,25 @@ fun DetailScreen(
} }
} }
// Section 5 — PostStatsSection // File attachment
if (post.fileUrl != null) {
AnimatedVisibility(
visible = sectionVisible[6].value,
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
FileAttachmentCard(
fileUrl = post.fileUrl,
fileName = post.fileName ?: "File"
)
}
}
}
// Section 7 — PostStatsSection
AnimatedVisibility( AnimatedVisibility(
visible = sectionVisible[5].value, visible = sectionVisible[7].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 {
@ -358,6 +400,43 @@ 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)
)
}
}
}
}
} }
} }

View file

@ -33,6 +33,7 @@ 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
@ -74,20 +75,26 @@ 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
@ -109,10 +116,16 @@ 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) }
@ -201,8 +214,19 @@ fun FeedScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { showAccountSwitcher = true } modifier = Modifier.clickable { showAccountSwitcher = true }
) { ) {
// Account color indicator // Site icon or account avatar
if (activeAccount != null) { val siteIconUrl = siteData?.icon ?: siteData?.logo
if (siteIconUrl != null) {
AsyncImage(
model = siteIconUrl,
contentDescription = "Site icon",
modifier = Modifier
.size(24.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(8.dp))
} else if (activeAccount != null) {
AccountAvatar( AccountAvatar(
account = activeAccount!!, account = activeAccount!!,
size = 28 size = 28
@ -211,18 +235,19 @@ 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 = activeAccount?.name ?: "Swoosh", text = displayName,
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 text = activeAccount!!.blogUrl.toDisplayUrl(),
.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,
@ -238,7 +263,7 @@ fun FeedScreen(
} }
} }
if (accounts.size > 1 || accounts.isNotEmpty()) { if (accounts.size > 1) {
Icon( Icon(
Icons.Default.KeyboardArrowDown, Icons.Default.KeyboardArrowDown,
contentDescription = "Switch account", contentDescription = "Switch account",
@ -305,20 +330,17 @@ fun FeedScreen(
) )
} }
// Active tag filter bar // Tag filter chips
if (state.activeTagFilter != null) { AnimatedVisibility(
FilterChip( visible = !isSearchActive && tagsEnabled && popularTags.isNotEmpty(),
onClick = { viewModel.clearTagFilter() }, enter = fadeIn(SwooshMotion.quick()) + expandVertically(),
label = { Text("#${state.activeTagFilter}") }, exit = fadeOut(SwooshMotion.quick()) + shrinkVertically()
selected = true, ) {
leadingIcon = { TagFilterChipsBar(
Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp)) tags = popularTags,
}, activeTagFilter = state.activeTagFilter,
trailingIcon = { onTagSelected = { viewModel.filterByTag(it) },
Icon(Icons.Default.Close, contentDescription = "Clear filter", modifier = Modifier.size(16.dp)) onClearFilter = { viewModel.clearTagFilter() }
},
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 4.dp)
) )
} }
@ -567,7 +589,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 -> viewModel.filterByTag(tag) }, onTagClick = { tag -> if (tagsEnabled) viewModel.filterByTag(tag) },
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState
) )
} }
@ -593,7 +615,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 -> viewModel.filterByTag(tag) }, onTagClick = { tag -> if (tagsEnabled) viewModel.filterByTag(tag) },
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState
) )
} }
@ -746,6 +768,12 @@ 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()
@ -754,19 +782,29 @@ 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 = if (selected) targetValue = when {
MaterialTheme.colorScheme.primaryContainer selected && filter == PostFilter.SENT -> sentColor.copy(alpha = 0.2f)
else selected -> MaterialTheme.colorScheme.primaryContainer
MaterialTheme.colorScheme.surface, else -> 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 = { Text(filter.displayName) }, label = {
Text(
filter.displayName,
color = if (selected && filter == PostFilter.SENT) sentColor
else Color.Unspecified
)
},
colors = FilterChipDefaults.filterChipColors( colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = containerColor selectedContainerColor = containerColor
) )
@ -775,6 +813,58 @@ fun FilterChipsBar(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TagFilterChipsBar(
tags: List<GhostTagFull>,
activeTagFilter: String?,
onTagSelected: (String) -> Unit,
onClearFilter: () -> Unit
) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// "All tags" chip
item {
val isAllSelected = activeTagFilter == null
FilterChip(
selected = isAllSelected,
onClick = { onClearFilter() },
label = { Text("All tags") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
// Tag chips
items(tags, key = { it.id ?: it.name }) { tag ->
val isSelected = activeTagFilter != null && activeTagFilter.equals(tag.name, ignoreCase = true)
FilterChip(
selected = isSelected,
onClick = {
if (isSelected) onClearFilter() else onTagSelected(tag.name)
},
label = {
val postCount = tag.count?.posts
if (postCount != null) {
Text("${tag.name} ($postCount)")
} else {
Text(tag.name)
}
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
}
}
@Composable @Composable
fun SortButton( fun SortButton(
currentSort: SortOrder, currentSort: SortOrder,
@ -1087,10 +1177,7 @@ fun AccountListItem(
}, },
supportingContent = { supportingContent = {
Text( Text(
account.blogUrl account.blogUrl.toDisplayUrl(),
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -1352,11 +1439,6 @@ 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()
@ -1415,7 +1497,8 @@ 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 = displayText, text = if (expanded || post.textContent.length <= 280) post.textContent
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
) )
@ -1483,6 +1566,18 @@ fun PostCardContent(
} }
} }
// Video player (compact)
if (post.videoUrl != null) {
Spacer(modifier = Modifier.height(12.dp))
VideoPlayer(url = post.videoUrl, compact = true)
}
// Audio player (compact)
if (post.audioUrl != null) {
Spacer(modifier = Modifier.height(12.dp))
AudioPlayer(url = post.audioUrl)
}
// Link preview // 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))
@ -1519,23 +1614,28 @@ fun PostCardContent(
} }
} }
// Hashtag tags (bold colored text, not chips) // File attachment
if (post.fileUrl != null) {
Spacer(modifier = Modifier.height(12.dp))
FileAttachmentCard(
fileUrl = post.fileUrl,
fileName = post.fileName ?: "File"
)
}
// Tags display
if (post.tags.isNotEmpty()) { if (post.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
@OptIn(ExperimentalLayoutApi::class) Text(
FlowRow( text = post.tags.joinToString(" \u00B7 ") { "#$it" },
horizontalArrangement = Arrangement.spacedBy(8.dp), style = MaterialTheme.typography.labelSmall,
verticalArrangement = Arrangement.spacedBy(4.dp) color = MaterialTheme.colorScheme.primary,
) { maxLines = 1,
post.tags.forEach { tag -> overflow = TextOverflow.Ellipsis,
Text( modifier = Modifier.clickable {
text = "#$tag", post.tags.firstOrNull()?.let { onTagClick(it) }
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onTagClick(tag) }
)
} }
} )
} }
// Queue status // Queue status
@ -1543,6 +1643,7 @@ 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 -> ""
@ -1553,7 +1654,7 @@ fun PostCardContent(
label = queueLabel, label = queueLabel,
isUploading = isUploading isUploading = isUploading
) )
if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED) { if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED || post.queueStatus == QueueStatus.QUEUED_EMAIL_ONLY) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
TextButton(onClick = onCancelQueue) { TextButton(onClick = onCancelQueue) {
Text("Cancel", style = MaterialTheme.typography.labelSmall) Text("Cancel", style = MaterialTheme.typography.labelSmall)
@ -1568,12 +1669,15 @@ 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)
@ -1582,19 +1686,28 @@ fun PostCardContent(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Box( if (isSent) {
modifier = Modifier Icon(
.size(8.dp) Icons.Default.Email,
.clip(CircleShape) contentDescription = "Sent",
.background(statusColor) modifier = Modifier.size(12.dp),
) 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 = "·", text = "\u00B7",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -1635,8 +1748,35 @@ fun PostCardContent(
) )
} }
// Share action (copies link to clipboard) // Share / Copy content action
if (isPublished && hasShareableUrl) { if (isSent) {
// For sent (email-only) posts, show "Copy content" instead of "Share"
val copyContext = LocalContext.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable {
val clipboard = copyContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Post content", post.textContent))
snackbarHostState?.let { host ->
coroutineScope.launch {
host.showSnackbar("Content copied to clipboard")
}
}
}
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy content",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Copy",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else if (isPublished && hasShareableUrl) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable { modifier = Modifier.clickable {
@ -2089,8 +2229,10 @@ 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))
@ -2108,3 +2250,57 @@ 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
)
}
}
}
}

View file

@ -9,10 +9,12 @@ 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
@ -36,9 +38,12 @@ 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())
@ -71,6 +76,12 @@ 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()
@ -237,8 +248,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
accountManager.setActiveAccount(accountId) accountManager.setActiveAccount(accountId)
ApiClient.reset() ApiClient.reset()
// Re-create repository to pick up new account // Re-create repositories to pick up new account
repository = PostRepository(getApplication()) repository = PostRepository(getApplication())
tagRepository = TagRepository(getApplication())
refreshAccountsList() refreshAccountsList()
@ -296,6 +308,25 @@ 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() }
@ -312,6 +343,15 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
fun refreshTagsEnabled() {
val enabled = tagsPreferences.isTagsEnabled()
_tagsEnabled.value = enabled
if (!enabled) {
_popularTags.value = emptyList()
_uiState.update { it.copy(activeTagFilter = null) }
}
}
fun filterByTag(tag: String) { fun filterByTag(tag: String) {
_uiState.update { it.copy(activeTagFilter = tag) } _uiState.update { it.copy(activeTagFilter = tag) }
refresh() refresh()
@ -505,18 +545,74 @@ 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 imageUrls = extractImageUrlsFromMobiledoc(mobiledoc) val mobiledocCards = parseMobiledocCards(mobiledoc)
// Use feature_image as primary, then add mobiledoc images (avoiding duplicates) // Use feature_image as primary, then add mobiledoc images, then HTML 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 imageUrls) { for (url in mobiledocCards.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,
@ -527,6 +623,8 @@ 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,
@ -537,34 +635,16 @@ 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()
} }
@ -586,6 +666,8 @@ 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,
@ -597,7 +679,10 @@ 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
) )
} }

View file

@ -0,0 +1,437 @@
package com.swoosh.microblog.ui.members
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.swoosh.microblog.data.model.GhostMember
import java.time.Duration
import java.time.Instant
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun MemberDetailScreen(
member: GhostMember,
onBack: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Member") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header: large avatar, name, email
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
DetailAvatar(
avatarUrl = member.avatar_image,
name = member.name ?: member.email ?: "?",
modifier = Modifier.size(80.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = member.name ?: "Unknown",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
if (member.email != null) {
Text(
text = member.email,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
// Quick stat tiles: status, open rate, emails
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
QuickStatCard(
modifier = Modifier.weight(1f),
value = (member.status ?: "free").replaceFirstChar { it.uppercase() },
label = "Status",
icon = if (member.status == "paid") Icons.Default.Diamond else Icons.Default.Person
)
QuickStatCard(
modifier = Modifier.weight(1f),
value = member.email_open_rate?.let { "${it.toInt()}%" } ?: "N/A",
label = "Open rate",
icon = Icons.Default.MailOutline
)
QuickStatCard(
modifier = Modifier.weight(1f),
value = "${member.email_count ?: 0}",
label = "Emails",
icon = Icons.Default.Email
)
}
// Subscription section (paid only)
val activeSubscriptions = member.subscriptions?.filter { it.status == "active" }
if (!activeSubscriptions.isNullOrEmpty()) {
Text(
"Subscription",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
activeSubscriptions.forEach { sub ->
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
sub.tier?.name?.let {
DetailRow("Tier", it)
}
sub.price?.let { price ->
val amount = price.amount?.let { "$${it / 100.0}" } ?: "N/A"
val interval = price.interval ?: ""
DetailRow("Price", "$amount / $interval")
price.currency?.let { DetailRow("Currency", it.uppercase()) }
}
sub.status?.let {
DetailRow("Status", it.replaceFirstChar { c -> c.uppercase() })
}
sub.start_date?.let {
DetailRow("Started", formatDate(it))
}
sub.current_period_end?.let {
DetailRow("Renews", formatDate(it))
}
if (sub.cancel_at_period_end == true) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small
) {
Text(
"Cancels at end of period",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
}
}
// Activity section
Text(
"Activity",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
member.created_at?.let {
DetailRow("Joined", formatDate(it))
}
member.last_seen_at?.let {
DetailRow("Last seen", formatRelativeTimeLong(it))
}
member.geolocation?.let {
if (it.isNotBlank()) {
DetailRow("Location", it)
}
}
}
}
// Newsletters section
val newsletters = member.newsletters
if (!newsletters.isNullOrEmpty()) {
Text(
"Newsletters",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
newsletters.forEach { newsletter ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = true,
onCheckedChange = null, // read-only
enabled = false
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = newsletter.name ?: newsletter.slug ?: newsletter.id,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
// Labels section
val labels = member.labels
if (!labels.isNullOrEmpty()) {
Text(
"Labels",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
labels.forEach { label ->
@Suppress("DEPRECATION")
AssistChip(
onClick = { },
label = { Text(label.name) },
leadingIcon = {
Icon(
Icons.Default.Label,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
}
}
}
// Email activity
val emailCount = member.email_count ?: 0
val emailOpened = member.email_opened_count ?: 0
if (emailCount > 0) {
Text(
"Email Activity",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
DetailRow("Emails sent", "$emailCount")
DetailRow("Emails opened", "$emailOpened")
val openRate = if (emailCount > 0) emailOpened.toFloat() / emailCount else 0f
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Open rate",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"${(openRate * 100).toInt()}%",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { openRate.coerceIn(0f, 1f) },
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
}
// Note
if (!member.note.isNullOrBlank()) {
Text(
"Note",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Text(
text = member.note,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@Composable
private fun QuickStatCard(
modifier: Modifier = Modifier,
value: String,
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector
) {
ElevatedCard(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
@Composable
private fun DetailRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
}
}
@Composable
private fun DetailAvatar(
avatarUrl: String?,
name: String,
modifier: Modifier = Modifier
) {
if (avatarUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(avatarUrl)
.crossfade(true)
.build(),
contentDescription = "Avatar for $name",
modifier = modifier.clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
val initial = name.firstOrNull()?.uppercase() ?: "?"
val colors = listOf(
0xFF6750A4, 0xFF00796B, 0xFFD32F2F, 0xFF1976D2, 0xFFF57C00
)
val colorIndex = name.hashCode().let { Math.abs(it) % colors.size }
val bgColor = androidx.compose.ui.graphics.Color(colors[colorIndex])
Box(
modifier = modifier
.clip(CircleShape)
.background(bgColor),
contentAlignment = Alignment.Center
) {
Text(
text = initial,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = androidx.compose.ui.graphics.Color.White
)
}
}
}
private fun formatDate(isoDate: String): String {
return try {
val instant = Instant.parse(isoDate)
val localDate = instant.atZone(java.time.ZoneId.systemDefault()).toLocalDate()
val formatter = java.time.format.DateTimeFormatter.ofPattern("MMM d, yyyy")
localDate.format(formatter)
} catch (e: Exception) {
isoDate
}
}
private fun formatRelativeTimeLong(isoDate: String): String {
return try {
val instant = Instant.parse(isoDate)
val now = Instant.now()
val duration = Duration.between(instant, now)
when {
duration.toMinutes() < 1 -> "Just now"
duration.toHours() < 1 -> "${duration.toMinutes()} minutes ago"
duration.toDays() < 1 -> "${duration.toHours()} hours ago"
duration.toDays() < 7 -> "${duration.toDays()} days ago"
duration.toDays() < 30 -> "${duration.toDays() / 7} weeks ago"
duration.toDays() < 365 -> "${duration.toDays() / 30} months ago"
else -> "${duration.toDays() / 365} years ago"
}
} catch (e: Exception) {
isoDate
}
}

View file

@ -0,0 +1,385 @@
package com.swoosh.microblog.ui.members
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.swoosh.microblog.data.model.GhostMember
import java.time.Duration
import java.time.Instant
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MembersScreen(
viewModel: MembersViewModel = viewModel(),
onMemberClick: (GhostMember) -> Unit = {},
onBack: () -> Unit = {}
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
// Trigger load more when near the bottom
val shouldLoadMore = remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
lastVisibleItem >= state.members.size - 3 && state.hasMore && !state.isLoadingMore
}
}
LaunchedEffect(shouldLoadMore.value) {
if (shouldLoadMore.value) {
viewModel.loadMore()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Members (${state.totalCount})") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Search field
OutlinedTextField(
value = state.searchQuery,
onValueChange = { viewModel.search(it) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Search members...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (state.searchQuery.isNotEmpty()) {
IconButton(onClick = { viewModel.search("") }) {
Icon(Icons.Default.Close, contentDescription = "Clear")
}
}
},
singleLine = true
)
// Filter row
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
MemberFilter.entries.forEachIndexed { index, filter ->
SegmentedButton(
selected = state.filter == filter,
onClick = { viewModel.updateFilter(filter) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = MemberFilter.entries.size
)
) {
Text(filter.displayName)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
when {
state.isLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
state.error != null && state.members.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.ErrorOutline,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = state.error ?: "Failed to load members",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(onClick = { viewModel.loadMembers() }) {
Text("Retry")
}
}
}
}
state.members.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No members found",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(
items = state.members,
key = { it.id }
) { member ->
MemberRow(
member = member,
onClick = { onMemberClick(member) }
)
}
if (state.isLoadingMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
}
}
}
}
}
}
@Composable
private fun MemberRow(
member: GhostMember,
onClick: () -> Unit
) {
val isNew = member.created_at?.let {
try {
val created = Instant.parse(it)
Duration.between(created, Instant.now()).toDays() < 7
} catch (e: Exception) {
false
}
} ?: false
val isPaid = member.status == "paid"
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar
MemberAvatar(
avatarUrl = member.avatar_image,
name = member.name ?: member.email ?: "?",
modifier = Modifier.size(44.dp)
)
Spacer(modifier = Modifier.width(12.dp))
// Name, email, badges
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = member.name ?: member.email ?: "Unknown",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
if (isPaid) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.extraSmall
) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Diamond,
contentDescription = "Paid",
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.width(2.dp))
Text(
"PAID",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
if (isNew) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = MaterialTheme.shapes.extraSmall
) {
Text(
"NEW",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
if (member.email != null && member.name != null) {
Text(
text = member.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
// Open rate bar
val openRate = member.email_open_rate
if (openRate != null) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
LinearProgressIndicator(
progress = { (openRate / 100f).toFloat().coerceIn(0f, 1f) },
modifier = Modifier
.weight(1f)
.height(4.dp),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${openRate.toInt()}%",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.width(8.dp))
// Relative time
member.last_seen_at?.let { lastSeen ->
Text(
text = formatRelativeTime(lastSeen),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun MemberAvatar(
avatarUrl: String?,
name: String,
modifier: Modifier = Modifier
) {
if (avatarUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(avatarUrl)
.crossfade(true)
.build(),
contentDescription = "Avatar for $name",
modifier = modifier.clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
val initial = name.firstOrNull()?.uppercase() ?: "?"
val colors = listOf(
0xFF6750A4, 0xFF00796B, 0xFFD32F2F, 0xFF1976D2, 0xFFF57C00
)
val colorIndex = name.hashCode().let { Math.abs(it) % colors.size }
val bgColor = androidx.compose.ui.graphics.Color(colors[colorIndex])
Box(
modifier = modifier
.clip(CircleShape)
.background(bgColor),
contentAlignment = Alignment.Center
) {
Text(
text = initial,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = androidx.compose.ui.graphics.Color.White
)
}
}
}
private fun formatRelativeTime(isoDate: String): String {
return try {
val instant = Instant.parse(isoDate)
val now = Instant.now()
val duration = Duration.between(instant, now)
when {
duration.toMinutes() < 1 -> "now"
duration.toHours() < 1 -> "${duration.toMinutes()}m"
duration.toDays() < 1 -> "${duration.toHours()}h"
duration.toDays() < 7 -> "${duration.toDays()}d"
duration.toDays() < 30 -> "${duration.toDays() / 7}w"
duration.toDays() < 365 -> "${duration.toDays() / 30}mo"
else -> "${duration.toDays() / 365}y"
}
} catch (e: Exception) {
""
}
}

View file

@ -0,0 +1,135 @@
package com.swoosh.microblog.ui.members
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.model.GhostMember
import com.swoosh.microblog.data.repository.MemberRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
enum class MemberFilter(val displayName: String, val ghostFilter: String?) {
ALL("All", null),
FREE("Free", "status:free"),
PAID("Paid", "status:paid")
}
data class MembersUiState(
val members: List<GhostMember> = emptyList(),
val totalCount: Int = 0,
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val hasMore: Boolean = false,
val currentPage: Int = 1,
val filter: MemberFilter = MemberFilter.ALL,
val searchQuery: String = "",
val error: String? = null
)
class MembersViewModel(application: Application) : AndroidViewModel(application) {
private val repository = MemberRepository(application)
private val _uiState = MutableStateFlow(MembersUiState())
val uiState: StateFlow<MembersUiState> = _uiState.asStateFlow()
private var searchJob: Job? = null
init {
loadMembers()
}
fun loadMembers() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null, currentPage = 1) }
val filter = buildFilter()
val result = repository.fetchMembers(page = 1, limit = 15, filter = filter)
result.fold(
onSuccess = { response ->
_uiState.update {
it.copy(
members = response.members,
totalCount = response.meta?.pagination?.total ?: response.members.size,
hasMore = response.meta?.pagination?.next != null,
currentPage = 1,
isLoading = false
)
}
},
onFailure = { e ->
_uiState.update {
it.copy(isLoading = false, error = e.message)
}
}
)
}
}
fun loadMore() {
val state = _uiState.value
if (state.isLoadingMore || !state.hasMore) return
viewModelScope.launch {
val nextPage = state.currentPage + 1
_uiState.update { it.copy(isLoadingMore = true) }
val filter = buildFilter()
val result = repository.fetchMembers(page = nextPage, limit = 15, filter = filter)
result.fold(
onSuccess = { response ->
_uiState.update {
it.copy(
members = it.members + response.members,
hasMore = response.meta?.pagination?.next != null,
currentPage = nextPage,
isLoadingMore = false
)
}
},
onFailure = { e ->
_uiState.update {
it.copy(isLoadingMore = false, error = e.message)
}
}
)
}
}
fun updateFilter(newFilter: MemberFilter) {
if (newFilter == _uiState.value.filter) return
_uiState.update { it.copy(filter = newFilter) }
loadMembers()
}
fun search(query: String) {
_uiState.update { it.copy(searchQuery = query) }
searchJob?.cancel()
searchJob = viewModelScope.launch {
delay(300) // debounce
loadMembers()
}
}
private fun buildFilter(): String? {
val parts = mutableListOf<String>()
// Status filter
_uiState.value.filter.ghostFilter?.let { parts.add(it) }
// Search filter
val query = _uiState.value.searchQuery.trim()
if (query.isNotEmpty()) {
parts.add("name:~'$query',email:~'$query'")
}
return parts.takeIf { it.isNotEmpty() }?.joinToString("+")
}
}

View file

@ -11,6 +11,7 @@ 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.*
@ -25,15 +26,21 @@ 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 {
@ -46,6 +53,11 @@ 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(
@ -56,12 +68,13 @@ 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.STATS, Routes.SETTINGS) private val bottomBarRoutes = setOf(Routes.FEED, Routes.NEWSLETTER, Routes.STATS, Routes.SETTINGS)
@Composable @Composable
fun SwooshNavGraph( fun SwooshNavGraph(
@ -73,6 +86,7 @@ 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()
@ -95,6 +109,7 @@ 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) {
@ -255,6 +270,12 @@ 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)
} }
) )
} }
@ -266,7 +287,33 @@ 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(
@ -300,6 +347,50 @@ fun SwooshNavGraph(
} }
) )
} }
composable(
Routes.PAGES,
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
) {
PagesScreen(
onBack = { navController.popBackStack() }
)
}
composable(
Routes.MEMBERS,
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
) {
MembersScreen(
onMemberClick = { member ->
selectedMember = member
navController.navigate(Routes.MEMBER_DETAIL)
},
onBack = { navController.popBackStack() }
)
}
composable(
Routes.MEMBER_DETAIL,
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
) {
val member = selectedMember
if (member != null) {
MemberDetailScreen(
member = member,
onBack = { navController.popBackStack() }
)
}
}
} }
} }
} }

View file

@ -0,0 +1,301 @@
package com.swoosh.microblog.ui.newsletter
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.People
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.swoosh.microblog.data.NewsletterPreferences
import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.data.model.GhostNewsletter
import com.swoosh.microblog.ui.animation.SwooshMotion
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewsletterScreen() {
val context = LocalContext.current
val newsletterPreferences = remember { NewsletterPreferences(context) }
var newsletterEnabled by remember { mutableStateOf(newsletterPreferences.isNewsletterEnabled()) }
var validationStatus by remember { mutableStateOf<String?>(null) }
var newsletters by remember { mutableStateOf<List<GhostNewsletter>>(emptyList()) }
var isLoading by remember { mutableStateOf(false) }
var subscriberCount by remember { mutableStateOf<Int?>(null) }
// Load newsletters on launch if enabled
LaunchedEffect(newsletterEnabled) {
if (newsletterEnabled) {
isLoading = true
try {
val repository = PostRepository(context)
val result = repository.fetchNewsletters()
result.fold(
onSuccess = {
newsletters = it
validationStatus = "${it.size} newsletter(s) found"
},
onFailure = {
validationStatus = "Could not load newsletters"
}
)
// Fetch subscriber count
val membersResult = repository.fetchSubscriberCount()
membersResult.fold(
onSuccess = { subscriberCount = it },
onFailure = { /* ignore */ }
)
} catch (_: Exception) {
validationStatus = "Could not load newsletters"
}
isLoading = false
} else {
newsletters = emptyList()
validationStatus = null
subscriberCount = null
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Newsletter") }
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState())
) {
// Enable/Disable toggle
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Enable newsletter features",
style = MaterialTheme.typography.bodyLarge
)
}
Switch(
checked = newsletterEnabled,
onCheckedChange = { enabled ->
newsletterEnabled = enabled
newsletterPreferences.setNewsletterEnabled(enabled)
}
)
}
Text(
text = "Show newsletter sending options when publishing posts",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Newsletter details (when enabled)
AnimatedVisibility(
visible = newsletterEnabled,
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
if (isLoading) {
Card(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
} else {
// Subscriber count card
if (subscriberCount != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
Icons.Default.People,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Column {
Text(
text = "$subscriberCount",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Subscribers",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
}
// Newsletters list
if (newsletters.isNotEmpty()) {
Text(
"Your Newsletters",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
newsletters.forEach { newsletter ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
Icons.Default.Email,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = newsletter.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
if (!newsletter.description.isNullOrBlank()) {
Text(
text = newsletter.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Status: ${newsletter.status}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Visibility: ${newsletter.visibility}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
} else if (validationStatus != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = validationStatus!!,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(16.dp)
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Info card
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "How it works",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "When enabled, the publish dialog offers options to send posts as newsletters or email-only content to your subscribers.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Disabled state info
AnimatedVisibility(
visible = !newsletterEnabled,
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.Email,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Newsletter features are disabled",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Enable to send posts as newsletters to your subscribers",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
}
}
}
}

View file

@ -0,0 +1,393 @@
package com.swoosh.microblog.ui.pages
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.swoosh.microblog.data.MobiledocBuilder
import com.swoosh.microblog.data.model.GhostPage
import com.swoosh.microblog.ui.components.ConfirmationDialog
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PagesScreen(
onBack: () -> Unit,
viewModel: PagesViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
if (uiState.isEditing || uiState.isCreating) {
PageEditorScreen(
page = uiState.editingPage,
isCreating = uiState.isCreating,
isLoading = uiState.isLoading,
error = uiState.error,
blogUrl = viewModel.getBlogUrl(),
onSave = { title, content, slug, status ->
viewModel.savePage(title, content, slug, status)
},
onUpdate = { id, page ->
viewModel.updatePage(id, page)
},
onCancel = { viewModel.cancelEditing() }
)
} else {
PagesListScreen(
pages = uiState.pages,
isLoading = uiState.isLoading,
error = uiState.error,
onBack = onBack,
onCreatePage = { viewModel.startCreating() },
onEditPage = { page -> viewModel.startEditing(page) },
onDeletePage = { id -> viewModel.deletePage(id) },
onRefresh = { viewModel.loadPages() }
)
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun PagesListScreen(
pages: List<GhostPage>,
isLoading: Boolean,
error: String?,
onBack: () -> Unit,
onCreatePage: () -> Unit,
onEditPage: (GhostPage) -> Unit,
onDeletePage: (String) -> Unit,
onRefresh: () -> Unit
) {
var deletePageId by remember { mutableStateOf<String?>(null) }
var deletePageTitle by remember { mutableStateOf("") }
var expandedMenuPageId by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Pages") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = onCreatePage) {
Icon(Icons.Default.Add, "New page")
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (isLoading && pages.isEmpty()) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
} else if (pages.isEmpty()) {
Text(
text = "No pages yet.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.Center)
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(pages, key = { it.id ?: it.hashCode() }) { page ->
Box {
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onEditPage(page) },
onLongClick = { expandedMenuPageId = page.id }
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = page.title ?: "Untitled",
style = MaterialTheme.typography.titleMedium
)
if (!page.slug.isNullOrBlank()) {
Text(
text = "/${page.slug}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
StatusChip(status = page.status ?: "draft")
}
}
DropdownMenu(
expanded = expandedMenuPageId == page.id,
onDismissRequest = { expandedMenuPageId = null }
) {
DropdownMenuItem(
text = { Text("Edit") },
onClick = {
expandedMenuPageId = null
onEditPage(page)
}
)
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
expandedMenuPageId = null
deletePageId = page.id
deletePageTitle = page.title ?: "Untitled"
}
)
}
}
}
}
}
if (error != null) {
Snackbar(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp),
action = {
TextButton(onClick = onRefresh) { Text("Retry") }
}
) {
Text(error)
}
}
}
}
if (deletePageId != null) {
ConfirmationDialog(
title = "Delete Page?",
message = "Delete \"$deletePageTitle\"? This cannot be undone.",
confirmLabel = "Delete",
onConfirm = {
deletePageId?.let { onDeletePage(it) }
deletePageId = null
},
onDismiss = { deletePageId = null }
)
}
}
@Composable
private fun StatusChip(status: String) {
val (color, label) = when (status) {
"published" -> Color(0xFF4CAF50) to "Published"
else -> Color(0xFF9C27B0) to "Draft"
}
AssistChip(
onClick = {},
label = { Text(label, style = MaterialTheme.typography.labelSmall) },
colors = AssistChipDefaults.assistChipColors(
labelColor = color
),
border = BorderStroke(1.dp, color.copy(alpha = 0.5f))
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PageEditorScreen(
page: GhostPage?,
isCreating: Boolean,
isLoading: Boolean,
error: String?,
blogUrl: String?,
onSave: (title: String, content: String, slug: String?, status: String) -> Unit,
onUpdate: (id: String, page: GhostPage) -> Unit,
onCancel: () -> Unit
) {
val context = LocalContext.current
val isNew = isCreating && page == null
var title by remember(page) { mutableStateOf(page?.title ?: "") }
var content by remember(page) { mutableStateOf(page?.plaintext ?: "") }
var slug by remember(page) { mutableStateOf(page?.slug ?: "") }
var selectedStatus by remember(page) {
mutableStateOf(if (page?.status == "published") "published" else "draft")
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isNew) "New page" else "Edit page") },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Cancel")
}
},
actions = {
IconButton(
onClick = {
if (isNew) {
onSave(title, content, slug.takeIf { it.isNotBlank() }, selectedStatus)
} else {
val mobiledoc = MobiledocBuilder.build(content)
val updatedPage = GhostPage(
title = title,
mobiledoc = mobiledoc,
slug = slug.takeIf { it.isNotBlank() },
status = selectedStatus,
updated_at = page?.updated_at
)
page?.id?.let { onUpdate(it, updatedPage) }
}
},
enabled = title.isNotBlank() && !isLoading
) {
Icon(Icons.Default.Check, "Save")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
OutlinedTextField(
value = content,
onValueChange = { content = it },
label = { Text("Content") },
modifier = Modifier.fillMaxWidth(),
minLines = 8
)
OutlinedTextField(
value = slug,
onValueChange = { slug = it },
label = { Text("Slug (optional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
// Status radio group
Text("Status", style = MaterialTheme.typography.titleSmall)
Column(modifier = Modifier.selectableGroup()) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selectedStatus == "draft",
onClick = { selectedStatus = "draft" },
role = Role.RadioButton
)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedStatus == "draft",
onClick = null
)
Spacer(modifier = Modifier.width(8.dp))
Text("Draft")
}
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selectedStatus == "published",
onClick = { selectedStatus = "published" },
role = Role.RadioButton
)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedStatus == "published",
onClick = null
)
Spacer(modifier = Modifier.width(8.dp))
Text("Publish")
}
}
// Open in browser for published pages
if (!isNew && page?.status == "published" && !page.slug.isNullOrBlank() && blogUrl != null) {
val pageUrl = "${blogUrl.removeSuffix("/")}/${page.slug}/"
OutlinedButton(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(pageUrl))
context.startActivity(intent)
},
modifier = Modifier.fillMaxWidth()
) {
Text("Open in browser")
}
}
// Revert to draft for published pages
if (!isNew && page?.status == "published") {
TextButton(
onClick = { selectedStatus = "draft" },
modifier = Modifier.fillMaxWidth()
) {
Text("Revert to draft")
}
}
if (isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View file

@ -0,0 +1,111 @@
package com.swoosh.microblog.ui.pages
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.MobiledocBuilder
import com.swoosh.microblog.data.model.GhostPage
import com.swoosh.microblog.data.repository.PageRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class PagesViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PageRepository(application)
private val _uiState = MutableStateFlow(PagesUiState())
val uiState: StateFlow<PagesUiState> = _uiState.asStateFlow()
init {
loadPages()
}
fun loadPages() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
repository.fetchPages().fold(
onSuccess = { pages ->
_uiState.update { it.copy(pages = pages, isLoading = false) }
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
fun savePage(title: String, content: String, slug: String?, status: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
val mobiledoc = MobiledocBuilder.build(content)
val page = GhostPage(
title = title,
mobiledoc = mobiledoc,
slug = slug?.takeIf { it.isNotBlank() },
status = status
)
repository.createPage(page).fold(
onSuccess = {
_uiState.update { it.copy(isEditing = false, isCreating = false, editingPage = null) }
loadPages()
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
fun updatePage(id: String, page: GhostPage) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
repository.updatePage(id, page).fold(
onSuccess = {
_uiState.update { it.copy(isEditing = false, isCreating = false, editingPage = null) }
loadPages()
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
fun deletePage(id: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
repository.deletePage(id).fold(
onSuccess = { loadPages() },
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
fun startEditing(page: GhostPage) {
_uiState.update { it.copy(editingPage = page, isEditing = true, isCreating = false) }
}
fun startCreating() {
_uiState.update { it.copy(editingPage = null, isEditing = false, isCreating = true) }
}
fun cancelEditing() {
_uiState.update { it.copy(editingPage = null, isEditing = false, isCreating = false) }
}
fun getBlogUrl(): String? = repository.getBlogUrl()
}
data class PagesUiState(
val pages: List<GhostPage> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val editingPage: GhostPage? = null,
val isEditing: Boolean = false,
val isCreating: Boolean = false
)

View file

@ -1,24 +1,41 @@
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
@ -30,11 +47,15 @@ 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()
@ -75,6 +96,169 @@ 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))
@ -102,10 +286,7 @@ fun SettingsScreen(
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
Text( Text(
text = activeAccount.blogUrl text = activeAccount.blogUrl.toDisplayUrl(),
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -128,6 +309,30 @@ 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 },
@ -202,6 +407,69 @@ fun SettingsScreen(
} }
} }
@Composable
fun TagsSettingsSection(onNavigateToTags: () -> Unit = {}) {
val context = LocalContext.current
val tagsPreferences = remember { TagsPreferences(context) }
var tagsEnabled by remember { mutableStateOf(tagsPreferences.isTagsEnabled()) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Enable tags",
style = MaterialTheme.typography.bodyLarge
)
}
Switch(
checked = tagsEnabled,
onCheckedChange = { enabled ->
tagsEnabled = enabled
tagsPreferences.setTagsEnabled(enabled)
}
)
}
Text(
text = "Show tag management and tag filters in feed",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Manage tags navigation
AnimatedVisibility(
visible = tagsEnabled,
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
) {
OutlinedButton(
onClick = onNavigateToTags,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
) {
Icon(
Icons.Default.Tag,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Manage Tags")
}
}
}
}
}
@Composable @Composable
fun ThemeModeSelector( fun ThemeModeSelector(
currentMode: ThemeMode, currentMode: ThemeMode,

View file

@ -9,24 +9,30 @@ 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
@ -34,6 +40,7 @@ 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
@ -102,6 +109,124 @@ 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
@ -252,6 +377,7 @@ fun SetupScreen(
} }
} }
} }
}
} }
} }
} }

View file

@ -4,6 +4,7 @@ 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
@ -16,6 +17,7 @@ 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
@ -34,6 +36,30 @@ 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()) {
@ -83,7 +109,39 @@ 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
@ -110,5 +168,12 @@ 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
) )

View file

@ -1,33 +1,40 @@
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.Create import androidx.compose.material.icons.filled.*
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),
@ -49,6 +56,39 @@ 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(
@ -114,6 +154,57 @@ 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",
@ -140,6 +231,80 @@ fun StatsScreen(
} }
} }
// Members section
if (memberStats != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Members",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "$animatedMembersTotal",
label = "Total",
icon = Icons.Default.People
)
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "+$animatedMembersNew",
label = "New this week",
icon = Icons.Default.PersonAdd
)
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "${String.format("%.1f", animatedOpenRate)}%",
label = "Open rate",
icon = Icons.Default.MailOutline
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "$animatedMembersFree",
label = "Free",
icon = Icons.Default.Person
)
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "$animatedMembersPaid",
label = "Paid",
icon = Icons.Default.Diamond
)
MemberStatsCard(
modifier = Modifier.weight(1f),
value = formatMrr(animatedMembersMrr),
label = "MRR",
icon = Icons.Default.AttachMoney
)
}
if (onNavigateToMembers != null) {
TextButton(
onClick = onNavigateToMembers,
modifier = Modifier.align(Alignment.End)
) {
Text("See all members")
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
}
}
if (state.error != null) { if (state.error != null) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
@ -152,6 +317,77 @@ fun StatsScreen(
} }
} }
private fun formatMrr(cents: Int): String {
val dollars = cents / 100.0
return if (dollars >= 1000) {
String.format("$%.1fk", dollars / 1000)
} else {
String.format("$%.0f", dollars)
}
}
@Composable
private fun TagProgressBar(
tag: GhostTagFull,
maxCount: Int
) {
val postCount = tag.count?.posts ?: 0
val progress = postCount.toFloat() / maxCount.toFloat()
val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = tween(600),
label = "tagProgress_${tag.name}"
)
val barColor = tag.accent_color?.let { parseHexColor(it) }
?: MaterialTheme.colorScheme.primary
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(barColor)
)
Text(
text = tag.name,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = "$postCount",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp)),
color = barColor,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
}
}
@Composable @Composable
private fun StatsCard( private fun StatsCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -188,6 +424,42 @@ private fun StatsCard(
} }
} }
@Composable
private fun MemberStatsCard(
modifier: Modifier = Modifier,
value: String,
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector
) {
ElevatedCard(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable @Composable
private fun WritingStatRow(label: String, value: String) { private fun WritingStatRow(label: String, value: String) {
Row( Row(

View file

@ -4,8 +4,13 @@ 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
@ -15,6 +20,8 @@ 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()
@ -28,52 +35,94 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(isLoading = true) } _uiState.update { it.copy(isLoading = true) }
try { try {
// Get local posts // Launch posts, members, and tags fetches in parallel
val localPosts = repository.getAllLocalPostsList() val postsDeferred = async {
val localPosts = repository.getAllLocalPostsList()
// Get remote posts val remotePosts = mutableListOf<FeedPost>()
val remotePosts = mutableListOf<FeedPost>() var page = 1
var page = 1 var hasMore = true
var hasMore = true while (hasMore) {
while (hasMore) { val result = repository.fetchPosts(page = page, limit = 50)
val result = repository.fetchPosts(page = page, limit = 50) result.fold(
result.fold( onSuccess = { response ->
onSuccess = { response -> remotePosts.addAll(response.posts.map { ghost ->
remotePosts.addAll(response.posts.map { ghost -> FeedPost(
FeedPost( ghostId = ghost.id,
ghostId = ghost.id, title = ghost.title ?: "",
title = ghost.title ?: "", textContent = ghost.plaintext ?: ghost.html?.replace(Regex("<[^>]*>"), "") ?: "",
textContent = ghost.plaintext ?: ghost.html?.replace(Regex("<[^>]*>"), "") ?: "", htmlContent = ghost.html,
htmlContent = ghost.html, imageUrl = ghost.feature_image,
imageUrl = ghost.feature_image, linkUrl = null,
linkUrl = null, linkTitle = null,
linkTitle = null, linkDescription = null,
linkDescription = null, linkImageUrl = null,
linkImageUrl = null, tags = ghost.tags?.map { it.name } ?: emptyList(),
status = ghost.status ?: "draft", status = ghost.status ?: "draft",
publishedAt = ghost.published_at, publishedAt = ghost.published_at,
createdAt = ghost.created_at, createdAt = ghost.created_at,
updatedAt = ghost.updated_at, updatedAt = ghost.updated_at,
isLocal = false isLocal = false
) )
}) })
hasMore = response.meta?.pagination?.next != null hasMore = response.meta?.pagination?.next != null
page++ page++
}, },
onFailure = { onFailure = {
hasMore = false hasMore = false
} }
) )
// Safety limit if (page > 20) break
if (page > 20) break }
val localGhostIds = localPosts.mapNotNull { it.ghostId }.toSet()
val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds }
Pair(localPosts, uniqueRemotePosts)
} }
// Remove remote duplicates that exist locally val membersDeferred = async {
val localGhostIds = localPosts.mapNotNull { it.ghostId }.toSet() try {
val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds } val membersResult = memberRepository.fetchAllMembers()
membersResult.getOrNull()?.let { members ->
memberRepository.getMemberStats(members)
}
} catch (e: Exception) {
null
}
}
val tagsDeferred = async {
try {
tagRepository.fetchTags().getOrNull()
?.sortedByDescending { it.count?.posts ?: 0 }
?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
val (localPosts, uniqueRemotePosts) = postsDeferred.await()
val memberStats = membersDeferred.await()
val tagStats = tagsDeferred.await()
val stats = OverallStats.calculate(localPosts, uniqueRemotePosts) 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) }
} }
@ -83,6 +132,10 @@ 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
) )

View file

@ -0,0 +1,448 @@
package com.swoosh.microblog.ui.tags
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.swoosh.microblog.data.model.GhostTagFull
import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.ConfirmationDialog
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TagsScreen(
onBack: () -> Unit,
viewModel: TagsViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// If editing a tag, show the edit screen
if (state.editingTag != null) {
TagEditScreen(
tag = state.editingTag!!,
isLoading = state.isLoading,
error = state.error,
onSave = viewModel::saveTag,
onDelete = { id -> viewModel.deleteTag(id) },
onBack = viewModel::cancelEditing
)
return
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Tags") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = { viewModel.startCreating() }) {
Icon(Icons.Default.Add, "Create tag")
}
IconButton(onClick = { viewModel.loadTags() }) {
Icon(Icons.Default.Refresh, "Refresh")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Search field
OutlinedTextField(
value = state.searchQuery,
onValueChange = viewModel::updateSearchQuery,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Search tags...") },
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp))
},
trailingIcon = {
if (state.searchQuery.isNotBlank()) {
IconButton(onClick = { viewModel.updateSearchQuery("") }) {
Icon(Icons.Default.Close, "Clear search", modifier = Modifier.size(18.dp))
}
}
}
)
// Loading indicator
if (state.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
// Error message
if (state.error != null) {
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
// Tags list
if (state.filteredTags.isEmpty() && !state.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Tag,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = if (state.searchQuery.isNotBlank()) "No tags match \"${state.searchQuery}\""
else "No tags yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (state.searchQuery.isBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Tap + to create your first tag",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
state.filteredTags,
key = { it.id ?: it.name }
) { tag ->
TagCard(
tag = tag,
onClick = { viewModel.startEditing(tag) }
)
}
}
}
}
}
}
@Composable
private fun TagCard(
tag: GhostTagFull,
onClick: () -> Unit
) {
OutlinedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Accent color dot
val accentColor = tag.accent_color?.let { parseHexColor(it) }
?: MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(accentColor)
)
// Tag info
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = tag.name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (tag.count?.posts != null) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer
) {
Text(
text = "${tag.count.posts} posts",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
if (!tag.description.isNullOrBlank()) {
Text(
text = tag.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Icon(
Icons.Default.ChevronRight,
contentDescription = "Edit",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TagEditScreen(
tag: GhostTagFull,
isLoading: Boolean,
error: String?,
onSave: (GhostTagFull) -> Unit,
onDelete: (String) -> Unit,
onBack: () -> Unit
) {
val isNew = tag.id == null
var name by remember(tag) { mutableStateOf(tag.name) }
var description by remember(tag) { mutableStateOf(tag.description ?: "") }
var accentColor by remember(tag) { mutableStateOf(tag.accent_color ?: "") }
var visibility by remember(tag) { mutableStateOf(tag.visibility ?: "public") }
var showDeleteDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isNew) "Create Tag" else "Edit Tag") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
if (isLoading) {
Box(
modifier = Modifier.size(48.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
} else {
TextButton(
onClick = {
onSave(
tag.copy(
name = name,
description = description.ifBlank { null },
accent_color = accentColor.ifBlank { null },
visibility = visibility
)
)
},
enabled = name.isNotBlank()
) {
Text("Save")
}
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Name
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = name.isBlank()
)
// Slug (read-only, only for existing tags)
if (!isNew && tag.slug != null) {
OutlinedTextField(
value = tag.slug,
onValueChange = {},
label = { Text("Slug") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
readOnly = true,
enabled = false
)
}
// Description
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 4
)
// Accent color
OutlinedTextField(
value = accentColor,
onValueChange = { accentColor = it },
label = { Text("Accent Color (hex)") },
placeholder = { Text("#FF5722") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = {
if (accentColor.isNotBlank()) {
val color = parseHexColor(accentColor)
Box(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.background(color)
)
}
}
)
// Visibility radio buttons
Text(
text = "Visibility",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { visibility = "public" }
) {
RadioButton(
selected = visibility == "public",
onClick = { visibility = "public" }
)
Text("Public", style = MaterialTheme.typography.bodyMedium)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { visibility = "internal" }
) {
RadioButton(
selected = visibility == "internal",
onClick = { visibility = "internal" }
)
Text("Internal", style = MaterialTheme.typography.bodyMedium)
}
}
// Error
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
// Delete button (only for existing tags)
if (!isNew) {
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = { showDeleteDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Delete Tag")
}
}
}
}
if (showDeleteDialog && tag.id != null) {
ConfirmationDialog(
title = "Delete Tag?",
message = "Delete \"${tag.name}\"? This will remove the tag from all posts.",
confirmLabel = "Delete",
onConfirm = {
showDeleteDialog = false
onDelete(tag.id)
},
onDismiss = { showDeleteDialog = false }
)
}
}
/**
* Parses a hex color string (e.g., "#FF5722" or "FF5722") into a Color.
* Returns a default color if parsing fails.
*/
fun parseHexColor(hex: String): Color {
return try {
val cleanHex = hex.removePrefix("#")
if (cleanHex.length == 6) {
Color(android.graphics.Color.parseColor("#$cleanHex"))
} else {
Color(0xFF888888)
}
} catch (e: Exception) {
Color(0xFF888888)
}
}

View file

@ -0,0 +1,120 @@
package com.swoosh.microblog.ui.tags
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.model.GhostTagFull
import com.swoosh.microblog.data.repository.TagRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class TagsViewModel(application: Application) : AndroidViewModel(application) {
private val tagRepository = TagRepository(application)
private val _uiState = MutableStateFlow(TagsUiState())
val uiState: StateFlow<TagsUiState> = _uiState.asStateFlow()
init {
loadTags()
}
fun loadTags() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
tagRepository.fetchTags().fold(
onSuccess = { tags ->
_uiState.update { it.copy(tags = tags, isLoading = false) }
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
fun updateSearchQuery(query: String) {
_uiState.update { it.copy(searchQuery = query) }
}
fun startEditing(tag: GhostTagFull) {
_uiState.update { it.copy(editingTag = tag) }
}
fun startCreating() {
_uiState.update {
it.copy(editingTag = GhostTagFull(name = ""))
}
}
fun cancelEditing() {
_uiState.update { it.copy(editingTag = null) }
}
fun saveTag(tag: GhostTagFull) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
if (tag.id != null) {
// Update existing tag
tagRepository.updateTag(tag.id, tag).fold(
onSuccess = {
_uiState.update { it.copy(editingTag = null) }
loadTags()
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
} else {
// Create new tag
tagRepository.createTag(
name = tag.name,
description = tag.description,
accentColor = tag.accent_color
).fold(
onSuccess = {
_uiState.update { it.copy(editingTag = null) }
loadTags()
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
}
fun deleteTag(id: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
tagRepository.deleteTag(id).fold(
onSuccess = {
_uiState.update { it.copy(editingTag = null) }
loadTags()
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
}
data class TagsUiState(
val tags: List<GhostTagFull> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val searchQuery: String = "",
val editingTag: GhostTagFull? = null
) {
val filteredTags: List<GhostTagFull>
get() = if (searchQuery.isBlank()) tags
else tags.filter { it.name.contains(searchQuery, ignoreCase = true) }
}

View file

@ -18,6 +18,8 @@ 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()
@ -66,25 +68,73 @@ 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(
post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription, text = post.content,
post.imageAlt imageUrls = allImageUrls,
linkUrl = post.linkUrl,
linkTitle = post.linkTitle,
linkDescription = post.linkDescription,
imageAlt = post.imageAlt,
videoUrl = videoUrl,
audioUrl = audioUrl,
fileUrl = uploadedFileUrl,
fileName = post.fileName,
fileSize = 0 // Size not stored in LocalPost; Ghost only needs src/fileName
) )
// Parse tags from JSON stored in LocalPost // 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,
@ -92,13 +142,17 @@ 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) repository.updatePost(post.ghostId, ghostPost, newsletter = newsletterSlug)
} else { } else {
repository.createPost(ghostPost) repository.createPost(ghostPost, newsletter = newsletterSlug)
} }
result.fold( result.fold(

View file

@ -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", null, null, null) val resultB = MobiledocBuilder.build("Hello")
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",
"https://test.com", linkUrl = "https://test.com",
"Test Title", linkTitle = "Test Title",
"Test Desc" linkDescription = "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", "https://test.com", null, null) val result = MobiledocBuilder.build("Text", linkUrl = "https://test.com")
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,8 +257,9 @@ 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", null, null, null, "Post text",
"https://example.com/photo.jpg", "A sunset" imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "A sunset"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json) assertNotNull(json)
@ -267,8 +268,9 @@ 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", null, null, null, "Text",
"https://example.com/photo.jpg", "Alt text" imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "Alt text"
) )
assertTrue("Should contain image card type", result.contains("\"image\"")) assertTrue("Should contain image card type", result.contains("\"image\""))
} }
@ -276,8 +278,9 @@ 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", null, null, null, "Text",
"https://example.com/photo.jpg", "Alt text" imageUrls = listOf("https://example.com/photo.jpg"),
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"))
} }
@ -285,8 +288,9 @@ 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", null, null, null, "Text",
"https://example.com/photo.jpg", "A beautiful sunset" imageUrls = listOf("https://example.com/photo.jpg"),
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")
@ -300,8 +304,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", null, null, null, "Text",
"https://example.com/photo.jpg", null imageUrls = listOf("https://example.com/photo.jpg")
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards") val cards = json.getAsJsonArray("cards")
@ -313,8 +317,9 @@ 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", null, null, null, "Text",
"https://example.com/photo.jpg", "Alt" imageUrls = listOf("https://example.com/photo.jpg"),
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
@ -325,8 +330,9 @@ 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", null, null, null, "Text",
"https://example.com/photo.jpg", "Alt" imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "Alt"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections") val sections = json.getAsJsonArray("sections")
@ -336,8 +342,12 @@ 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", "https://link.com", "Link Title", "Link Desc", "Text",
"https://example.com/photo.jpg", "Image alt" imageUrls = listOf("https://example.com/photo.jpg"),
linkUrl = "https://link.com",
linkTitle = "Link Title",
linkDescription = "Link Desc",
imageAlt = "Image alt"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards") val cards = json.getAsJsonArray("cards")
@ -350,8 +360,12 @@ 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", "https://link.com", "Title", "Desc", "Text",
"https://example.com/photo.jpg", "Alt" imageUrls = listOf("https://example.com/photo.jpg"),
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")
@ -361,8 +375,9 @@ 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", null, null, null, "Text",
"https://example.com/photo.jpg", "He said \"hello\"" imageUrls = listOf("https://example.com/photo.jpg"),
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)
@ -370,10 +385,7 @@ class MobiledocBuilderTest {
@Test @Test
fun `build without image produces no image card`() { fun `build without image produces no image card`() {
val result = MobiledocBuilder.build( val result = MobiledocBuilder.build("Text")
"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)
} }
@ -381,8 +393,9 @@ 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", null, null, null, "Text",
"https://example.com/photo.jpg", "Alt" imageUrls = listOf("https://example.com/photo.jpg"),
imageAlt = "Alt"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections") val sections = json.getAsJsonArray("sections")
@ -394,8 +407,11 @@ 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", "https://link.com", "Title", null, "Text",
"https://example.com/photo.jpg", "Alt" imageUrls = listOf("https://example.com/photo.jpg"),
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")
@ -414,7 +430,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"), null, null, null "Hello", listOf("https://example.com/img.jpg")
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json) assertNotNull(json)
@ -423,7 +439,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"), null, null, null "Hello", listOf("https://example.com/img.jpg")
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertEquals(1, json.getAsJsonArray("cards").size()) assertEquals(1, json.getAsJsonArray("cards").size())
@ -436,7 +452,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"), null, null, null "Hello", listOf("https://example.com/img.jpg")
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertEquals(2, json.getAsJsonArray("sections").size()) assertEquals(2, json.getAsJsonArray("sections").size())
@ -449,7 +465,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, null, null, null) val result = MobiledocBuilder.build("Hello", images)
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json) assertNotNull(json)
} }
@ -461,7 +477,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, null, null, null) val result = MobiledocBuilder.build("Hello", images)
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertEquals(3, json.getAsJsonArray("cards").size()) assertEquals(3, json.getAsJsonArray("cards").size())
} }
@ -472,7 +488,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, null, null, null) val result = MobiledocBuilder.build("Hello", images)
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())
@ -484,7 +500,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, null, null, null) val result = MobiledocBuilder.build("Hello", images)
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()) {
@ -500,7 +516,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, null, null, null) val result = MobiledocBuilder.build("Hello", images)
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)
@ -512,7 +528,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, "https://example.com", "Title", "Desc" "Hello", images, linkUrl = "https://example.com", linkTitle = "Title", linkDescription = "Desc"
) )
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards") val cards = json.getAsJsonArray("cards")
@ -528,7 +544,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, "https://example.com", "Title", "Desc" "Hello", images, linkUrl = "https://example.com", linkTitle = "Title", linkDescription = "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
@ -538,7 +554,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, null, null, null) val result = MobiledocBuilder.build("Hello", images)
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections") val sections = json.getAsJsonArray("sections")
@ -556,7 +572,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(), null, null, null) val result = MobiledocBuilder.build("Hello", emptyList())
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())
@ -565,14 +581,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(), null, null, null) val resultB = MobiledocBuilder.build("Hello", emptyList())
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, null, null, null) val result = MobiledocBuilder.build("Hello", images)
val json = JsonParser.parseString(result).asJsonObject val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json) assertNotNull(json)
} }
@ -585,7 +601,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, null, null, null, "First image alt") val result = MobiledocBuilder.build("Text", images, imageAlt = "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")
@ -597,4 +613,249 @@ 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)
}
} }

View file

@ -0,0 +1,98 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = android.app.Application::class)
class NewsletterPreferencesTest {
private lateinit var prefs: SharedPreferences
private lateinit var newsletterPreferences: NewsletterPreferences
private val testAccountId = "test-account-123"
@Before
fun setup() {
val context = RuntimeEnvironment.getApplication()
prefs = context.getSharedPreferences(NewsletterPreferences.PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().clear().commit()
newsletterPreferences = NewsletterPreferences(prefs, testAccountId)
}
// --- Default values ---
@Test
fun `default newsletter enabled is false`() {
assertFalse(newsletterPreferences.isNewsletterEnabled())
}
// --- Setting and getting ---
@Test
fun `setting newsletter enabled to true persists`() {
newsletterPreferences.setNewsletterEnabled(true)
assertTrue(newsletterPreferences.isNewsletterEnabled())
}
@Test
fun `setting newsletter enabled to false persists`() {
newsletterPreferences.setNewsletterEnabled(true)
newsletterPreferences.setNewsletterEnabled(false)
assertFalse(newsletterPreferences.isNewsletterEnabled())
}
@Test
fun `newsletter enabled persists across instances`() {
newsletterPreferences.setNewsletterEnabled(true)
val newInstance = NewsletterPreferences(prefs, testAccountId)
assertTrue(newInstance.isNewsletterEnabled())
}
@Test
fun `toggling on then off round-trips correctly`() {
newsletterPreferences.setNewsletterEnabled(true)
assertTrue(newsletterPreferences.isNewsletterEnabled())
newsletterPreferences.setNewsletterEnabled(false)
assertFalse(newsletterPreferences.isNewsletterEnabled())
}
// --- Per-account isolation ---
@Test
fun `different accounts have independent newsletter settings`() {
val prefs1 = NewsletterPreferences(prefs, "account-1")
val prefs2 = NewsletterPreferences(prefs, "account-2")
prefs1.setNewsletterEnabled(true)
prefs2.setNewsletterEnabled(false)
assertTrue(prefs1.isNewsletterEnabled())
assertFalse(prefs2.isNewsletterEnabled())
}
@Test
fun `enabling for one account does not affect another`() {
val prefs1 = NewsletterPreferences(prefs, "account-a")
val prefs2 = NewsletterPreferences(prefs, "account-b")
prefs1.setNewsletterEnabled(true)
assertTrue(prefs1.isNewsletterEnabled())
assertFalse(prefs2.isNewsletterEnabled())
}
@Test
fun `empty account id still works`() {
val emptyPrefs = NewsletterPreferences(prefs, "")
emptyPrefs.setNewsletterEnabled(true)
assertTrue(emptyPrefs.isNewsletterEnabled())
}
}

View file

@ -0,0 +1,161 @@
package com.swoosh.microblog.data
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.swoosh.microblog.data.model.GhostSite
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class SiteMetadataCacheTest {
private lateinit var cache: SiteMetadataCache
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Application>()
cache = SiteMetadataCache(context)
}
@Test
fun `save and get round trip`() {
val site = GhostSite(
title = "My Blog",
description = "A test blog",
logo = "https://example.com/logo.png",
icon = "https://example.com/icon.png",
accent_color = "#ff1a75",
url = "https://example.com/",
version = "5.82.0",
locale = "en"
)
cache.save("account-1", site)
val retrieved = cache.get("account-1")
assertNotNull(retrieved)
assertEquals("My Blog", retrieved!!.title)
assertEquals("A test blog", retrieved.description)
assertEquals("https://example.com/logo.png", retrieved.logo)
assertEquals("https://example.com/icon.png", retrieved.icon)
assertEquals("#ff1a75", retrieved.accent_color)
assertEquals("https://example.com/", retrieved.url)
assertEquals("5.82.0", retrieved.version)
assertEquals("en", retrieved.locale)
}
@Test
fun `get returns null for unknown account`() {
val result = cache.get("nonexistent-account")
assertNull(result)
}
@Test
fun `getVersion returns version string`() {
val site = GhostSite(
title = "Blog",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "5.82.0",
locale = null
)
cache.save("account-2", site)
assertEquals("5.82.0", cache.getVersion("account-2"))
}
@Test
fun `getVersion returns null for unknown account`() {
assertNull(cache.getVersion("nonexistent"))
}
@Test
fun `save overwrites existing data`() {
val site1 = GhostSite(
title = "Old Title",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "5.0.0",
locale = null
)
val site2 = GhostSite(
title = "New Title",
description = "Updated",
logo = null,
icon = null,
accent_color = null,
url = null,
version = "5.82.0",
locale = null
)
cache.save("account-3", site1)
cache.save("account-3", site2)
val retrieved = cache.get("account-3")
assertEquals("New Title", retrieved?.title)
assertEquals("Updated", retrieved?.description)
assertEquals("5.82.0", retrieved?.version)
}
@Test
fun `different accounts have independent data`() {
val site1 = GhostSite(
title = "Blog One",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "5.0.0",
locale = null
)
val site2 = GhostSite(
title = "Blog Two",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "4.48.0",
locale = null
)
cache.save("account-a", site1)
cache.save("account-b", site2)
assertEquals("Blog One", cache.get("account-a")?.title)
assertEquals("Blog Two", cache.get("account-b")?.title)
}
@Test
fun `remove deletes cached data`() {
val site = GhostSite(
title = "To Remove",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "5.0.0",
locale = null
)
cache.save("account-remove", site)
assertNotNull(cache.get("account-remove"))
cache.remove("account-remove")
assertNull(cache.get("account-remove"))
}
}

View file

@ -56,9 +56,9 @@ class ConvertersTest {
} }
} }
@Test(expected = IllegalArgumentException::class) @Test
fun `toPostStatus throws on invalid string`() { fun `toPostStatus returns DRAFT fallback on invalid string`() {
converters.toPostStatus("INVALID") assertEquals(PostStatus.DRAFT, converters.toPostStatus("INVALID"))
} }
// --- QueueStatus conversions --- // --- QueueStatus conversions ---
@ -112,9 +112,9 @@ class ConvertersTest {
} }
} }
@Test(expected = IllegalArgumentException::class) @Test
fun `toQueueStatus throws on invalid string`() { fun `toQueueStatus returns NONE fallback on invalid string`() {
converters.toQueueStatus("NONEXISTENT") assertEquals(QueueStatus.NONE, converters.toQueueStatus("NONEXISTENT"))
} }
// --- String list JSON serialization --- // --- String list JSON serialization ---

View file

@ -265,13 +265,13 @@ class GhostModelsTest {
// --- Enum values --- // --- Enum values ---
@Test @Test
fun `PostStatus has exactly 3 values`() { fun `PostStatus has exactly 4 values`() {
assertEquals(3, PostStatus.values().size) assertEquals(4, PostStatus.values().size)
} }
@Test @Test
fun `QueueStatus has exactly 5 values`() { fun `QueueStatus has exactly 6 values`() {
assertEquals(5, QueueStatus.values().size) assertEquals(6, QueueStatus.values().size)
} }
@Test @Test
@ -279,6 +279,7 @@ 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
@ -286,6 +287,7 @@ 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"))
} }
@ -422,4 +424,95 @@ 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)
}
} }

View file

@ -0,0 +1,280 @@
package com.swoosh.microblog.data.model
import com.google.gson.Gson
import org.junit.Assert.*
import org.junit.Test
class MemberModelsTest {
private val gson = Gson()
@Test
fun `GhostMember deserializes from JSON correctly`() {
val json = """{
"id": "member1",
"email": "test@example.com",
"name": "John Doe",
"status": "free",
"avatar_image": "https://example.com/avatar.jpg",
"email_count": 10,
"email_opened_count": 5,
"email_open_rate": 50.0,
"last_seen_at": "2026-03-15T10:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z",
"updated_at": "2026-03-15T10:00:00.000Z",
"note": "VIP member",
"geolocation": "Warsaw, Poland"
}"""
val member = gson.fromJson(json, GhostMember::class.java)
assertEquals("member1", member.id)
assertEquals("test@example.com", member.email)
assertEquals("John Doe", member.name)
assertEquals("free", member.status)
assertEquals("https://example.com/avatar.jpg", member.avatar_image)
assertEquals(10, member.email_count)
assertEquals(5, member.email_opened_count)
assertEquals(50.0, member.email_open_rate!!, 0.001)
assertEquals("VIP member", member.note)
assertEquals("Warsaw, Poland", member.geolocation)
}
@Test
fun `GhostMember deserializes with missing optional fields`() {
val json = """{"id": "member2"}"""
val member = gson.fromJson(json, GhostMember::class.java)
assertEquals("member2", member.id)
assertNull(member.email)
assertNull(member.name)
assertNull(member.status)
assertNull(member.avatar_image)
assertNull(member.email_count)
assertNull(member.email_opened_count)
assertNull(member.email_open_rate)
assertNull(member.last_seen_at)
assertNull(member.created_at)
assertNull(member.updated_at)
assertNull(member.labels)
assertNull(member.newsletters)
assertNull(member.subscriptions)
assertNull(member.note)
assertNull(member.geolocation)
}
@Test
fun `GhostMember deserializes with labels`() {
val json = """{
"id": "member3",
"labels": [
{"id": "label1", "name": "VIP", "slug": "vip"},
{"id": "label2", "name": "Beta", "slug": "beta"}
]
}"""
val member = gson.fromJson(json, GhostMember::class.java)
assertEquals(2, member.labels?.size)
assertEquals("VIP", member.labels?.get(0)?.name)
assertEquals("vip", member.labels?.get(0)?.slug)
assertEquals("Beta", member.labels?.get(1)?.name)
}
@Test
fun `GhostMember deserializes with newsletters`() {
val json = """{
"id": "member4",
"newsletters": [
{"id": "nl1", "name": "Weekly Digest", "slug": "weekly-digest"},
{"id": "nl2", "name": "Product Updates", "slug": "product-updates"}
]
}"""
val member = gson.fromJson(json, GhostMember::class.java)
assertEquals(2, member.newsletters?.size)
assertEquals("Weekly Digest", member.newsletters?.get(0)?.name)
assertEquals("nl2", member.newsletters?.get(1)?.id)
}
@Test
fun `GhostMember deserializes with subscriptions`() {
val json = """{
"id": "member5",
"status": "paid",
"subscriptions": [
{
"id": "sub1",
"status": "active",
"start_date": "2026-01-01T00:00:00.000Z",
"current_period_end": "2026-04-01T00:00:00.000Z",
"cancel_at_period_end": false,
"price": {"amount": 500, "currency": "USD", "interval": "month"},
"tier": {"id": "tier1", "name": "Gold"}
}
]
}"""
val member = gson.fromJson(json, GhostMember::class.java)
assertEquals("paid", member.status)
assertEquals(1, member.subscriptions?.size)
val sub = member.subscriptions!![0]
assertEquals("sub1", sub.id)
assertEquals("active", sub.status)
assertEquals(false, sub.cancel_at_period_end)
assertEquals(500, sub.price?.amount)
assertEquals("USD", sub.price?.currency)
assertEquals("month", sub.price?.interval)
assertEquals("Gold", sub.tier?.name)
}
@Test
fun `MembersResponse deserializes with members and pagination`() {
val json = """{
"members": [
{"id": "m1", "email": "a@example.com", "name": "Alice", "status": "free"},
{"id": "m2", "email": "b@example.com", "name": "Bob", "status": "paid"}
],
"meta": {
"pagination": {
"page": 1, "limit": 15, "pages": 3, "total": 42, "next": 2, "prev": null
}
}
}"""
val response = gson.fromJson(json, MembersResponse::class.java)
assertEquals(2, response.members.size)
assertEquals("m1", response.members[0].id)
assertEquals("Alice", response.members[0].name)
assertEquals("free", response.members[0].status)
assertEquals("Bob", response.members[1].name)
assertEquals("paid", response.members[1].status)
assertEquals(1, response.meta?.pagination?.page)
assertEquals(3, response.meta?.pagination?.pages)
assertEquals(42, response.meta?.pagination?.total)
assertEquals(2, response.meta?.pagination?.next)
assertNull(response.meta?.pagination?.prev)
}
@Test
fun `MembersResponse deserializes with empty members list`() {
val json = """{
"members": [],
"meta": {
"pagination": {
"page": 1, "limit": 15, "pages": 0, "total": 0, "next": null, "prev": null
}
}
}"""
val response = gson.fromJson(json, MembersResponse::class.java)
assertTrue(response.members.isEmpty())
assertEquals(0, response.meta?.pagination?.total)
}
@Test
fun `MemberLabel deserializes correctly`() {
val json = """{"id": "l1", "name": "Premium", "slug": "premium"}"""
val label = gson.fromJson(json, MemberLabel::class.java)
assertEquals("l1", label.id)
assertEquals("Premium", label.name)
assertEquals("premium", label.slug)
}
@Test
fun `MemberLabel allows null id and slug`() {
val json = """{"name": "Test"}"""
val label = gson.fromJson(json, MemberLabel::class.java)
assertNull(label.id)
assertEquals("Test", label.name)
assertNull(label.slug)
}
@Test
fun `MemberNewsletter deserializes correctly`() {
val json = """{"id": "n1", "name": "Daily News", "slug": "daily-news"}"""
val newsletter = gson.fromJson(json, MemberNewsletter::class.java)
assertEquals("n1", newsletter.id)
assertEquals("Daily News", newsletter.name)
assertEquals("daily-news", newsletter.slug)
}
@Test
fun `SubscriptionPrice deserializes correctly`() {
val json = """{"amount": 1000, "currency": "EUR", "interval": "year"}"""
val price = gson.fromJson(json, SubscriptionPrice::class.java)
assertEquals(1000, price.amount)
assertEquals("EUR", price.currency)
assertEquals("year", price.interval)
}
@Test
fun `SubscriptionTier deserializes correctly`() {
val json = """{"id": "t1", "name": "Premium"}"""
val tier = gson.fromJson(json, SubscriptionTier::class.java)
assertEquals("t1", tier.id)
assertEquals("Premium", tier.name)
}
@Test
fun `GhostMember serializes to JSON correctly`() {
val member = GhostMember(
id = "m1",
email = "test@test.com",
name = "Test User",
status = "free",
avatar_image = null,
email_count = 5,
email_opened_count = 3,
email_open_rate = 60.0,
last_seen_at = null,
created_at = "2026-01-01T00:00:00.000Z",
updated_at = null,
labels = emptyList(),
newsletters = emptyList(),
subscriptions = null,
note = null,
geolocation = null
)
val json = gson.toJson(member)
assertTrue(json.contains("\"id\":\"m1\""))
assertTrue(json.contains("\"email\":\"test@test.com\""))
assertTrue(json.contains("\"status\":\"free\""))
}
@Test
fun `MembersResponse reuses Meta and Pagination from GhostModels`() {
// Verify MembersResponse uses the same Meta/Pagination as PostsResponse
val json = """{
"members": [{"id": "m1"}],
"meta": {"pagination": {"page": 2, "limit": 50, "pages": 5, "total": 250, "next": 3, "prev": 1}}
}"""
val response = gson.fromJson(json, MembersResponse::class.java)
val pagination = response.meta?.pagination!!
assertEquals(2, pagination.page)
assertEquals(50, pagination.limit)
assertEquals(5, pagination.pages)
assertEquals(250, pagination.total)
assertEquals(3, pagination.next)
assertEquals(1, pagination.prev)
}
@Test
fun `GhostMember with zero email stats`() {
val json = """{
"id": "m1",
"email_count": 0,
"email_opened_count": 0,
"email_open_rate": null
}"""
val member = gson.fromJson(json, GhostMember::class.java)
assertEquals(0, member.email_count)
assertEquals(0, member.email_opened_count)
assertNull(member.email_open_rate)
}
@Test
fun `MemberSubscription with cancel_at_period_end true`() {
val json = """{
"id": "sub1",
"status": "active",
"cancel_at_period_end": true,
"price": {"amount": 900, "currency": "USD", "interval": "month"},
"tier": {"id": "t1", "name": "Basic"}
}"""
val sub = gson.fromJson(json, MemberSubscription::class.java)
assertEquals(true, sub.cancel_at_period_end)
assertEquals(900, sub.price?.amount)
}
}

View file

@ -0,0 +1,162 @@
package com.swoosh.microblog.data.model
import com.google.gson.Gson
import org.junit.Assert.*
import org.junit.Test
class PageModelsTest {
private val gson = Gson()
// --- GhostPage defaults ---
@Test
fun `GhostPage all fields default to null`() {
val page = GhostPage()
assertNull(page.id)
assertNull(page.title)
assertNull(page.slug)
assertNull(page.url)
assertNull(page.html)
assertNull(page.plaintext)
assertNull(page.mobiledoc)
assertNull(page.status)
assertNull(page.feature_image)
assertNull(page.custom_excerpt)
assertNull(page.created_at)
assertNull(page.updated_at)
assertNull(page.published_at)
}
@Test
fun `GhostPage stores all fields correctly`() {
val page = GhostPage(
id = "page-1",
title = "About",
slug = "about",
url = "https://blog.example.com/about/",
html = "<p>About us</p>",
plaintext = "About us",
mobiledoc = """{"version":"0.3.1"}""",
status = "published",
feature_image = "https://blog.example.com/img.jpg",
custom_excerpt = "Learn more about us",
created_at = "2024-01-01T00:00:00.000Z",
updated_at = "2024-06-15T12:00:00.000Z",
published_at = "2024-01-02T00:00:00.000Z"
)
assertEquals("page-1", page.id)
assertEquals("About", page.title)
assertEquals("about", page.slug)
assertEquals("https://blog.example.com/about/", page.url)
assertEquals("<p>About us</p>", page.html)
assertEquals("About us", page.plaintext)
assertEquals("published", page.status)
assertEquals("https://blog.example.com/img.jpg", page.feature_image)
assertEquals("Learn more about us", page.custom_excerpt)
assertEquals("2024-01-01T00:00:00.000Z", page.created_at)
assertEquals("2024-06-15T12:00:00.000Z", page.updated_at)
assertEquals("2024-01-02T00:00:00.000Z", page.published_at)
}
// --- GSON serialization ---
@Test
fun `GhostPage serializes to JSON correctly`() {
val page = GhostPage(
id = "abc123",
title = "Contact",
slug = "contact",
status = "published"
)
val json = gson.toJson(page)
assertTrue(json.contains("\"id\":\"abc123\""))
assertTrue(json.contains("\"title\":\"Contact\""))
assertTrue(json.contains("\"slug\":\"contact\""))
assertTrue(json.contains("\"status\":\"published\""))
}
@Test
fun `GhostPage deserializes from JSON correctly`() {
val json = """{"id":"xyz","title":"FAQ","slug":"faq","status":"draft"}"""
val page = gson.fromJson(json, GhostPage::class.java)
assertEquals("xyz", page.id)
assertEquals("FAQ", page.title)
assertEquals("faq", page.slug)
assertEquals("draft", page.status)
}
@Test
fun `GhostPage deserializes with missing optional fields`() {
val json = """{"id":"test"}"""
val page = gson.fromJson(json, GhostPage::class.java)
assertEquals("test", page.id)
assertNull(page.title)
assertNull(page.slug)
assertNull(page.html)
assertNull(page.status)
}
// --- PagesResponse ---
@Test
fun `PagesResponse deserializes with pages and pagination`() {
val json = """{
"pages": [{"id": "1", "title": "About"}, {"id": "2", "title": "Contact"}],
"meta": {"pagination": {"page": 1, "limit": 15, "pages": 1, "total": 2, "next": null, "prev": null}}
}"""
val response = gson.fromJson(json, PagesResponse::class.java)
assertEquals(2, response.pages.size)
assertEquals("1", response.pages[0].id)
assertEquals("About", response.pages[0].title)
assertEquals("2", response.pages[1].id)
assertEquals("Contact", response.pages[1].title)
assertEquals(1, response.meta?.pagination?.page)
assertEquals(2, response.meta?.pagination?.total)
assertNull(response.meta?.pagination?.next)
}
@Test
fun `PagesResponse deserializes with empty pages list`() {
val json = """{"pages": [], "meta": null}"""
val response = gson.fromJson(json, PagesResponse::class.java)
assertTrue(response.pages.isEmpty())
assertNull(response.meta)
}
// --- PageWrapper ---
@Test
fun `PageWrapper wraps pages for API request`() {
val wrapper = PageWrapper(listOf(GhostPage(title = "New Page", status = "draft")))
val json = gson.toJson(wrapper)
assertTrue(json.contains("\"pages\""))
assertTrue(json.contains("\"New Page\""))
}
@Test
fun `PageWrapper serializes single page correctly`() {
val page = GhostPage(
title = "About Us",
slug = "about-us",
status = "published",
mobiledoc = """{"version":"0.3.1","atoms":[],"cards":[],"markups":[],"sections":[[1,"p",[[0,[],0,"Welcome"]]]]}"""
)
val wrapper = PageWrapper(listOf(page))
val json = gson.toJson(wrapper)
assertTrue(json.contains("\"title\":\"About Us\""))
assertTrue(json.contains("\"slug\":\"about-us\""))
assertTrue(json.contains("\"status\":\"published\""))
}
@Test
fun `GhostPage updated_at is preserved for PUT requests`() {
val page = GhostPage(
id = "page-1",
title = "Updated Title",
updated_at = "2024-06-15T12:00:00.000Z"
)
val json = gson.toJson(page)
assertTrue(json.contains("\"updated_at\":\"2024-06-15T12:00:00.000Z\""))
}
}

View file

@ -8,8 +8,8 @@ class PostFilterTest {
// --- Enum values --- // --- Enum values ---
@Test @Test
fun `PostFilter has exactly 4 values`() { fun `PostFilter has exactly 5 values`() {
assertEquals(4, PostFilter.values().size) assertEquals(5, PostFilter.values().size)
} }
@Test @Test
@ -18,6 +18,7 @@ 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 ---
@ -107,4 +108,26 @@ 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())
}
} }

View file

@ -0,0 +1,129 @@
package com.swoosh.microblog.data.model
import com.google.gson.Gson
import org.junit.Assert.*
import org.junit.Test
class SiteModelsTest {
private val gson = Gson()
@Test
fun `deserialize full site response`() {
val json = """
{
"title": "My Ghost Blog",
"description": "A blog about things",
"logo": "https://example.com/logo.png",
"icon": "https://example.com/icon.png",
"accent_color": "#ff1a75",
"url": "https://example.com/",
"version": "5.82.0",
"locale": "en"
}
""".trimIndent()
val site = gson.fromJson(json, GhostSite::class.java)
assertEquals("My Ghost Blog", site.title)
assertEquals("A blog about things", site.description)
assertEquals("https://example.com/logo.png", site.logo)
assertEquals("https://example.com/icon.png", site.icon)
assertEquals("#ff1a75", site.accent_color)
assertEquals("https://example.com/", site.url)
assertEquals("5.82.0", site.version)
assertEquals("en", site.locale)
}
@Test
fun `deserialize site response with null fields`() {
val json = """
{
"title": "Minimal Blog",
"url": "https://minimal.ghost.io/"
}
""".trimIndent()
val site = gson.fromJson(json, GhostSite::class.java)
assertEquals("Minimal Blog", site.title)
assertNull(site.description)
assertNull(site.logo)
assertNull(site.icon)
assertNull(site.accent_color)
assertEquals("https://minimal.ghost.io/", site.url)
assertNull(site.version)
assertNull(site.locale)
}
@Test
fun `version parsing extracts major version`() {
val site = GhostSite(
title = "Test",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "5.82.0",
locale = null
)
val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull()
assertEquals(5, majorVersion)
}
@Test
fun `version parsing handles old version`() {
val site = GhostSite(
title = "Old Blog",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "4.48.9",
locale = null
)
val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull()
assertEquals(4, majorVersion)
assertTrue((majorVersion ?: 0) < 5)
}
@Test
fun `version parsing handles null version`() {
val site = GhostSite(
title = "No Version",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = null,
locale = null
)
val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull()
assertNull(majorVersion)
}
@Test
fun `serialize and deserialize round trip`() {
val original = GhostSite(
title = "Round Trip Blog",
description = "Testing serialization",
logo = "https://example.com/logo.png",
icon = "https://example.com/icon.png",
accent_color = "#15171a",
url = "https://example.com/",
version = "5.82.0",
locale = "pl"
)
val json = gson.toJson(original)
val deserialized = gson.fromJson(json, GhostSite::class.java)
assertEquals(original, deserialized)
}
}

View file

@ -0,0 +1,196 @@
package com.swoosh.microblog.data.model
import com.google.gson.Gson
import org.junit.Assert.*
import org.junit.Test
class TagModelsTest {
private val gson = Gson()
// --- GhostTagFull defaults ---
@Test
fun `GhostTagFull default id is null`() {
val tag = GhostTagFull(name = "test")
assertNull(tag.id)
}
@Test
fun `GhostTagFull default visibility is public`() {
val tag = GhostTagFull(name = "test")
assertEquals("public", tag.visibility)
}
@Test
fun `GhostTagFull default optional fields are null`() {
val tag = GhostTagFull(name = "test")
assertNull(tag.slug)
assertNull(tag.description)
assertNull(tag.feature_image)
assertNull(tag.accent_color)
assertNull(tag.count)
assertNull(tag.created_at)
assertNull(tag.updated_at)
assertNull(tag.url)
}
@Test
fun `GhostTagFull stores all fields`() {
val tag = GhostTagFull(
id = "tag-1",
name = "kotlin",
slug = "kotlin",
description = "Posts about Kotlin",
feature_image = "https://example.com/kotlin.png",
visibility = "public",
accent_color = "#FF5722",
count = TagCount(posts = 42),
created_at = "2024-01-01T00:00:00.000Z",
updated_at = "2024-06-15T12:00:00.000Z",
url = "https://blog.example.com/tag/kotlin/"
)
assertEquals("tag-1", tag.id)
assertEquals("kotlin", tag.name)
assertEquals("kotlin", tag.slug)
assertEquals("Posts about Kotlin", tag.description)
assertEquals("https://example.com/kotlin.png", tag.feature_image)
assertEquals("public", tag.visibility)
assertEquals("#FF5722", tag.accent_color)
assertEquals(42, tag.count?.posts)
assertEquals("2024-01-01T00:00:00.000Z", tag.created_at)
assertEquals("2024-06-15T12:00:00.000Z", tag.updated_at)
assertEquals("https://blog.example.com/tag/kotlin/", tag.url)
}
// --- TagCount ---
@Test
fun `TagCount stores post count`() {
val count = TagCount(posts = 10)
assertEquals(10, count.posts)
}
@Test
fun `TagCount allows null posts`() {
val count = TagCount(posts = null)
assertNull(count.posts)
}
// --- GSON serialization ---
@Test
fun `GhostTagFull serializes to JSON correctly`() {
val tag = GhostTagFull(
name = "android",
description = "Android development",
accent_color = "#3DDC84"
)
val json = gson.toJson(tag)
assertTrue(json.contains("\"name\":\"android\""))
assertTrue(json.contains("\"description\":\"Android development\""))
assertTrue(json.contains("\"accent_color\":\"#3DDC84\""))
}
@Test
fun `GhostTagFull deserializes from JSON correctly`() {
val json = """{
"id": "abc123",
"name": "tech",
"slug": "tech",
"description": "Technology posts",
"visibility": "public",
"accent_color": "#1E88E5",
"count": {"posts": 15},
"created_at": "2024-01-01T00:00:00.000Z",
"updated_at": "2024-06-01T00:00:00.000Z",
"url": "https://blog.example.com/tag/tech/"
}"""
val tag = gson.fromJson(json, GhostTagFull::class.java)
assertEquals("abc123", tag.id)
assertEquals("tech", tag.name)
assertEquals("tech", tag.slug)
assertEquals("Technology posts", tag.description)
assertEquals("public", tag.visibility)
assertEquals("#1E88E5", tag.accent_color)
assertEquals(15, tag.count?.posts)
assertEquals("2024-01-01T00:00:00.000Z", tag.created_at)
assertEquals("2024-06-01T00:00:00.000Z", tag.updated_at)
assertEquals("https://blog.example.com/tag/tech/", tag.url)
}
@Test
fun `GhostTagFull deserializes with missing optional fields`() {
val json = """{"name": "minimal"}"""
val tag = gson.fromJson(json, GhostTagFull::class.java)
assertEquals("minimal", tag.name)
assertNull(tag.id)
assertNull(tag.slug)
assertNull(tag.description)
assertNull(tag.accent_color)
assertNull(tag.count)
}
// --- TagsResponse ---
@Test
fun `TagsResponse deserializes with tags and meta`() {
val json = """{
"tags": [
{"id": "1", "name": "news", "slug": "news", "count": {"posts": 5}},
{"id": "2", "name": "tech", "slug": "tech", "count": {"posts": 12}}
],
"meta": {"pagination": {"page": 1, "limit": 15, "pages": 1, "total": 2, "next": null, "prev": null}}
}"""
val response = gson.fromJson(json, TagsResponse::class.java)
assertEquals(2, response.tags.size)
assertEquals("news", response.tags[0].name)
assertEquals(5, response.tags[0].count?.posts)
assertEquals("tech", response.tags[1].name)
assertEquals(12, response.tags[1].count?.posts)
assertNotNull(response.meta)
assertEquals(1, response.meta?.pagination?.page)
assertEquals(2, response.meta?.pagination?.total)
}
@Test
fun `TagsResponse deserializes with null meta`() {
val json = """{"tags": [{"name": "solo"}], "meta": null}"""
val response = gson.fromJson(json, TagsResponse::class.java)
assertEquals(1, response.tags.size)
assertNull(response.meta)
}
// --- TagWrapper ---
@Test
fun `TagWrapper wraps tags for API request`() {
val wrapper = TagWrapper(listOf(GhostTagFull(name = "new-tag", description = "A new tag")))
val json = gson.toJson(wrapper)
assertTrue(json.contains("\"tags\""))
assertTrue(json.contains("\"new-tag\""))
assertTrue(json.contains("\"A new tag\""))
}
@Test
fun `TagWrapper serializes accent_color`() {
val wrapper = TagWrapper(listOf(GhostTagFull(
name = "colored",
accent_color = "#FF0000"
)))
val json = gson.toJson(wrapper)
assertTrue(json.contains("\"accent_color\":\"#FF0000\""))
}
@Test
fun `TagCount zero posts`() {
val count = TagCount(posts = 0)
assertEquals(0, count.posts)
}
@Test
fun `GhostTagFull with internal visibility`() {
val tag = GhostTagFull(name = "internal-tag", visibility = "internal")
assertEquals("internal", tag.visibility)
}
}

View file

@ -0,0 +1,371 @@
package com.swoosh.microblog.data.repository
import com.swoosh.microblog.data.model.*
import org.junit.Assert.*
import org.junit.Test
import java.time.Instant
import java.time.temporal.ChronoUnit
class MemberRepositoryTest {
// We test getMemberStats() which is a pure function — no Context needed
private fun createRepository(): MemberRepository? = null // Can't instantiate without Context
private fun getMemberStats(members: List<GhostMember>): MemberStats {
// Replicate the pure function logic to test it directly
// We use a standalone helper since the repository needs Context
val total = members.size
val free = members.count { it.status == "free" }
val paid = members.count { it.status == "paid" }
val oneWeekAgo = Instant.now().minus(7, ChronoUnit.DAYS)
val newThisWeek = members.count { member ->
member.created_at?.let {
try {
Instant.parse(it).isAfter(oneWeekAgo)
} catch (e: Exception) {
false
}
} ?: false
}
val openRates = members.mapNotNull { it.email_open_rate }
val avgOpenRate = if (openRates.isNotEmpty()) openRates.average() else null
val mrr = members.filter { it.status == "paid" }.sumOf { member ->
member.subscriptions?.sumOf { sub ->
if (sub.status == "active") {
val amount = sub.price?.amount ?: 0
when (sub.price?.interval) {
"year" -> amount / 12
"month" -> amount
else -> amount
}
} else 0
} ?: 0
}
return MemberStats(
total = total, free = free, paid = paid,
newThisWeek = newThisWeek, avgOpenRate = avgOpenRate, mrr = mrr
)
}
private fun makeMember(
id: String = "m1",
email: String? = null,
name: String? = null,
status: String? = "free",
openRate: Double? = null,
createdAt: String? = null,
subscriptions: List<MemberSubscription>? = null
) = GhostMember(
id = id, email = email, name = name, status = status,
avatar_image = null, email_count = null, email_opened_count = null,
email_open_rate = openRate, last_seen_at = null,
created_at = createdAt, updated_at = null,
labels = null, newsletters = null, subscriptions = subscriptions,
note = null, geolocation = null
)
@Test
fun `getMemberStats with empty list returns zero stats`() {
val stats = getMemberStats(emptyList())
assertEquals(0, stats.total)
assertEquals(0, stats.free)
assertEquals(0, stats.paid)
assertEquals(0, stats.newThisWeek)
assertNull(stats.avgOpenRate)
assertEquals(0, stats.mrr)
}
@Test
fun `getMemberStats counts total members`() {
val members = listOf(
makeMember(id = "m1"),
makeMember(id = "m2"),
makeMember(id = "m3")
)
val stats = getMemberStats(members)
assertEquals(3, stats.total)
}
@Test
fun `getMemberStats counts free and paid members`() {
val members = listOf(
makeMember(id = "m1", status = "free"),
makeMember(id = "m2", status = "free"),
makeMember(id = "m3", status = "paid"),
makeMember(id = "m4", status = "paid"),
makeMember(id = "m5", status = "paid")
)
val stats = getMemberStats(members)
assertEquals(5, stats.total)
assertEquals(2, stats.free)
assertEquals(3, stats.paid)
}
@Test
fun `getMemberStats counts new members this week`() {
val recentDate = Instant.now().minus(2, ChronoUnit.DAYS).toString()
val oldDate = Instant.now().minus(30, ChronoUnit.DAYS).toString()
val members = listOf(
makeMember(id = "m1", createdAt = recentDate),
makeMember(id = "m2", createdAt = recentDate),
makeMember(id = "m3", createdAt = oldDate)
)
val stats = getMemberStats(members)
assertEquals(2, stats.newThisWeek)
}
@Test
fun `getMemberStats handles null created_at for new this week`() {
val members = listOf(
makeMember(id = "m1", createdAt = null),
makeMember(id = "m2", createdAt = null)
)
val stats = getMemberStats(members)
assertEquals(0, stats.newThisWeek)
}
@Test
fun `getMemberStats handles invalid created_at date`() {
val members = listOf(
makeMember(id = "m1", createdAt = "not-a-date")
)
val stats = getMemberStats(members)
assertEquals(0, stats.newThisWeek)
}
@Test
fun `getMemberStats calculates average open rate`() {
val members = listOf(
makeMember(id = "m1", openRate = 40.0),
makeMember(id = "m2", openRate = 60.0),
makeMember(id = "m3", openRate = 80.0)
)
val stats = getMemberStats(members)
assertNotNull(stats.avgOpenRate)
assertEquals(60.0, stats.avgOpenRate!!, 0.001)
}
@Test
fun `getMemberStats skips null open rates in average`() {
val members = listOf(
makeMember(id = "m1", openRate = 50.0),
makeMember(id = "m2", openRate = null),
makeMember(id = "m3", openRate = 100.0)
)
val stats = getMemberStats(members)
assertNotNull(stats.avgOpenRate)
assertEquals(75.0, stats.avgOpenRate!!, 0.001) // (50 + 100) / 2
}
@Test
fun `getMemberStats returns null avgOpenRate when all rates are null`() {
val members = listOf(
makeMember(id = "m1", openRate = null),
makeMember(id = "m2", openRate = null)
)
val stats = getMemberStats(members)
assertNull(stats.avgOpenRate)
}
@Test
fun `getMemberStats calculates MRR from monthly subscriptions`() {
val members = listOf(
makeMember(
id = "m1", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
tier = null
)
)
),
makeMember(
id = "m2", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s2", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = 1000, currency = "USD", interval = "month"),
tier = null
)
)
)
)
val stats = getMemberStats(members)
assertEquals(1500, stats.mrr) // 500 + 1000
}
@Test
fun `getMemberStats converts yearly subscriptions to monthly MRR`() {
val members = listOf(
makeMember(
id = "m1", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = 12000, currency = "USD", interval = "year"),
tier = null
)
)
)
)
val stats = getMemberStats(members)
assertEquals(1000, stats.mrr) // 12000 / 12
}
@Test
fun `getMemberStats ignores canceled subscriptions in MRR`() {
val members = listOf(
makeMember(
id = "m1", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "canceled", start_date = null,
current_period_end = null, cancel_at_period_end = true,
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
tier = null
)
)
)
)
val stats = getMemberStats(members)
assertEquals(0, stats.mrr)
}
@Test
fun `getMemberStats ignores free members in MRR calculation`() {
val members = listOf(
makeMember(id = "m1", status = "free"),
makeMember(
id = "m2", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
tier = null
)
)
)
)
val stats = getMemberStats(members)
assertEquals(500, stats.mrr)
}
@Test
fun `getMemberStats handles paid member with no subscriptions`() {
val members = listOf(
makeMember(id = "m1", status = "paid", subscriptions = null)
)
val stats = getMemberStats(members)
assertEquals(1, stats.paid)
assertEquals(0, stats.mrr)
}
@Test
fun `getMemberStats handles paid member with empty subscriptions`() {
val members = listOf(
makeMember(id = "m1", status = "paid", subscriptions = emptyList())
)
val stats = getMemberStats(members)
assertEquals(1, stats.paid)
assertEquals(0, stats.mrr)
}
@Test
fun `getMemberStats mixed free and paid members comprehensive`() {
val recentDate = Instant.now().minus(1, ChronoUnit.DAYS).toString()
val oldDate = Instant.now().minus(60, ChronoUnit.DAYS).toString()
val members = listOf(
makeMember(id = "m1", status = "free", openRate = 40.0, createdAt = recentDate),
makeMember(id = "m2", status = "free", openRate = 60.0, createdAt = oldDate),
makeMember(
id = "m3", status = "paid", openRate = 80.0, createdAt = recentDate,
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
tier = SubscriptionTier(id = "t1", name = "Gold")
)
)
),
makeMember(
id = "m4", status = "paid", openRate = null, createdAt = oldDate,
subscriptions = listOf(
MemberSubscription(
id = "s2", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = 6000, currency = "USD", interval = "year"),
tier = SubscriptionTier(id = "t1", name = "Gold")
)
)
)
)
val stats = getMemberStats(members)
assertEquals(4, stats.total)
assertEquals(2, stats.free)
assertEquals(2, stats.paid)
assertEquals(2, stats.newThisWeek) // m1 and m3
assertEquals(60.0, stats.avgOpenRate!!, 0.001) // (40 + 60 + 80) / 3
assertEquals(1000, stats.mrr) // 500 + 6000/12 = 500 + 500
}
@Test
fun `getMemberStats handles null status members`() {
val members = listOf(
makeMember(id = "m1", status = null),
makeMember(id = "m2", status = "free")
)
val stats = getMemberStats(members)
assertEquals(2, stats.total)
assertEquals(1, stats.free)
assertEquals(0, stats.paid)
}
@Test
fun `getMemberStats handles subscription with null price`() {
val members = listOf(
makeMember(
id = "m1", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = null, tier = null
)
)
)
)
val stats = getMemberStats(members)
assertEquals(0, stats.mrr)
}
@Test
fun `getMemberStats handles subscription with null amount`() {
val members = listOf(
makeMember(
id = "m1", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = null, currency = "USD", interval = "month"),
tier = null
)
)
)
)
val stats = getMemberStats(members)
assertEquals(0, stats.mrr)
}
}

File diff suppressed because it is too large Load diff

BIN
pics/feed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
pics/newsletter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
pics/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

BIN
pics/stats.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB