FOSMVVM ViewModel Generator
Generate ViewModels following FOSMVVM architecture patterns.
Conceptual Foundation
For full architecture context, see FOSMVVMArchitecture.md | OpenClaw reference
A ViewModel is the bridge in the Model-View-ViewModel architecture:
CODEBLOCK0
Key insight: In FOSMVVM, ViewModels are:
- - Created by a Factory (either server-side or client-side)
- Localized during encoding (resolves all
@LocalizedString references) - Consumed by Views which just render the localized data
First Decision: Hosting Mode
This is a per-ViewModel decision. An app can mix both modes - for example, a standalone iPhone app with server-based sign-in.
The key question: Where does THIS ViewModel's data come from?
| Data Source | Hosting Mode | Factory |
|---|
| Server/Database | Server-Hosted | Hand-written |
| Local state/preferences |
Client-Hosted | Macro-generated |
|
ResponseError (caught error) |
Client-Hosted | Macro-generated |
Server-Hosted Mode
When data comes from a server:
- - Factory is hand-written on server (
ViewModelFactory protocol) - Factory queries database, builds ViewModel
- Server localizes during JSON encoding
- Client receives fully localized ViewModel
Examples: Sign-in screen, user profile from API, dashboard with server data
Client-Hosted Mode
When data is local to the device:
- - Use INLINECODE2
- Macro auto-generates factory from init parameters
- Client bundles YAML resources
- Client localizes during encoding
Examples: Settings screen, onboarding, offline-first features, error display
Error Display Pattern
Error display is a classic client-hosted scenario. You already have the data from ResponseError - just wrap it in a specific ViewModel for that error:
CODEBLOCK1
Usage:
CODEBLOCK2
Each error scenario gets its own ViewModel:
- -
MoveIdeaErrorViewModel for INLINECODE5 - INLINECODE6 for INLINECODE7
- INLINECODE8 for settings form errors
Don't create a generic "ToastViewModel" or "ErrorViewModel" - that's unified error architecture, which we avoid.
Key insights:
- - No server request needed - you already caught the error
- The
LocalizableString properties in ResponseError are already localized (server did it) - Standard ViewModel → View encoding chain handles this correctly; already-localized strings pass through unchanged
- Client-hosted ViewModel wraps existing data; the macro generates the factory
Hybrid Apps
Many apps use both:
CODEBLOCK3
Same ViewModel patterns work in both modes - only the factory creation differs.
Core Responsibility: Shaping Data
A ViewModel's job is shaping data for presentation. This happens in two places:
- 1. Factory - what data is needed, how to transform it
- Localization - how to present it in context (including locale-aware ordering)
The View just renders - it should never compose, format, or reorder ViewModel properties.
What a ViewModel Contains
A ViewModel answers: "What does the View need to display?"
| Content Type | How It's Represented | Example |
|---|
| Static UI text | INLINECODE11 | Page titles, button labels (fixed text) |
| Dynamic enum values |
LocalizableString (stored) | Status/state display (see Enum Localization Pattern) |
| Dynamic data in text |
@LocalizedSubs | "Welcome, %{name}!" with substitutions |
| Composed text |
@LocalizedCompoundString | Full name from pieces (locale-aware order) |
| Formatted dates |
LocalizableDate |
createdAt: LocalizableDate |
| Formatted numbers |
LocalizableInt |
totalCount: LocalizableInt |
| Dynamic data | Plain properties |
content: String,
count: Int |
| Nested components | Child ViewModels |
cards: [CardViewModel] |
What a ViewModel Does NOT Contain
- - Database relationships (
@Parent, @Siblings) - Business logic or validation (that's in Fields protocols)
- Raw database IDs exposed to templates (use typed properties)
- Unlocalized strings that Views must look up
Anti-Pattern: Composition in Views
CODEBLOCK4
If you see + or string interpolation in a View, the shaping belongs in the ViewModel.
ViewModel Protocol Hierarchy
CODEBLOCK5
ViewModel provides:
- -
ServerRequestBody - Can be sent over HTTP as JSON - INLINECODE26 - Enables
@LocalizedString binding (via @ViewModel macro) - INLINECODE29 - Has
vmId for SwiftUI identity - INLINECODE31 - Has
stub() for testing/previews
RequestableViewModel adds:
- - Associated
Request type for fetching from server
Two Categories of ViewModels
1. Top-Level (RequestableViewModel)
Represents a full page or screen. Has:
- - An associated
ViewModelRequest type - A
ViewModelFactory that builds it from database - Child ViewModels embedded within it
CODEBLOCK6
2. Child (plain ViewModel)
Nested components built by their parent's factory. No Request type.
CODEBLOCK7
Display vs Form ViewModels
ViewModels serve two distinct purposes:
| Purpose | ViewModel Type | Adopts Fields? |
|---|
| Display data (read-only) | Display ViewModel | No |
| Collect user input (editable) |
Form ViewModel | Yes |
Display ViewModels
For showing data - cards, rows, lists, detail views:
CODEBLOCK8
Characteristics:
- - Properties are
let (read-only) - No validation needed
- No FormField definitions
- Just projects Model data for display
Form ViewModels
For collecting input - create forms, edit forms, settings:
CODEBLOCK9
Characteristics:
- - Properties are
var (editable) - Adopts a Fields protocol for validation
- Gets FormField definitions from Fields
- Gets validation logic from Fields
- Gets localized error messages from Fields
The Connection
CODEBLOCK10
Quick Decision Guide
The key question: "Is the user editing data in this ViewModel?"
- - No → Display ViewModel (no Fields)
- Yes → Form ViewModel (adopt Fields)
| ViewModel | User Edits? | Adopt Fields? |
|---|
| INLINECODE38 | No | No |
| INLINECODE39 |
No | No |
|
UserDetailViewModel | No | No |
|
UserFormViewModel | Yes |
UserFields |
|
CreateUserViewModel | Yes |
UserFields |
|
EditUserViewModel | Yes |
UserFields |
|
SettingsViewModel | Yes |
SettingsFields |
When to Use This Skill
- - Creating a new page or screen
- Adding a new UI component (card, row, modal, etc.)
- Displaying data from the database in a View
- Following an implementation plan that requires new ViewModels
What This Skill Generates
Server-Hosted: Top-Level ViewModel (4 files)
| File | Location | Purpose |
|---|
| INLINECODE49 | INLINECODE50 | The ViewModel struct |
| INLINECODE51 |
{ViewModelsTarget}/ | The ViewModelRequest type |
|
{Name}ViewModel.yml |
{ResourcesPath}/ | Localization strings |
|
{Name}ViewModel+Factory.swift |
{WebServerTarget}/ | Factory that builds from DB |
Client-Hosted: Top-Level ViewModel (2 files)
| File | Location | Purpose |
|---|
| INLINECODE57 | INLINECODE58 | ViewModel with clientHostedFactory option |
| INLINECODE60 |
{ResourcesPath}/ | Localization strings (bundled in app) |
No Request or Factory files needed - macro generates them!
Child ViewModels (1-2 files, either mode)
| File | Location | Purpose |
|---|
| INLINECODE62 | INLINECODE63 | The ViewModel struct |
| INLINECODE64 |
{ResourcesPath}/ | Localization (if has
@LocalizedString) |
Note: If child is only used by one parent and represents a summary/reference (not a full ViewModel), nest it inside the parent file instead. See Nested Child Types Pattern under Key Patterns.
Project Structure Configuration
| Placeholder | Description | Example |
|---|
| INLINECODE67 | Shared ViewModels SPM target | INLINECODE68 |
| INLINECODE69 |
Localization resources |
Sources/Resources |
|
{WebServerTarget} | Server-side target |
WebServer,
AppServer |
How to Use This Skill
Invocation:
/fosmvvm-viewmodel-generator
Prerequisites:
- - View requirements understood from conversation context
- Data source determined (server/database vs local state)
- Display vs Form decision made (if user input involved, Fields protocol exists)
Workflow integration:
This skill is typically used after discussing View requirements or reading specification files. The skill references conversation context automatically—no file paths or Q&A needed. For Form ViewModels, run fosmvvm-fields-generator first to create the Fields protocol.
Pattern Implementation
This skill references conversation context to determine ViewModel structure:
Hosting Mode Detection
From conversation context, the skill identifies:
- - Data source (server/database vs local state/preferences)
- Server-hosted → Hand-written factory, server-side localization
- Client-hosted → Macro-generated factory, client-side localization
ViewModel Design
From requirements already in context:
- - View purpose (page, modal, card, row component)
- Data needs (from database query, from AppState, from caught error)
- Static UI text (titles, labels, buttons requiring @LocalizedString)
- Child ViewModels (nested components)
- Hierarchy level (top-level RequestableViewModel vs child ViewModel)
Property Planning
Based on View requirements:
- - Display properties (data to render)
- Localization requirements (which properties use @LocalizedString)
- Identity strategy (singleton vmId vs instance-based vmId)
- Form adoption (whether ViewModel adopts Fields protocol)
File Generation
Server-Hosted Top-Level:
- 1. ViewModel struct (with
RequestableViewModel) - Request type
- YAML localization
- Factory implementation
Client-Hosted Top-Level:
- 1. ViewModel struct (with
clientHostedFactory option) - YAML localization
Child (either mode):
- 1. ViewModel struct
- YAML localization (if needed)
Context Sources
Skill references information from:
- - Prior conversation: View requirements, data sources discussed with user
- Specification files: If Claude has read UI specs or feature docs into context
- Fields protocols: From codebase or previous fosmvvm-fields-generator invocation
Key Patterns
The @ViewModel Macro
Always use the @ViewModel macro - it generates the propertyNames() method required for localization binding.
Server-Hosted (basic macro):
CODEBLOCK11
Client-Hosted (with factory generation):
CODEBLOCK12
Stubbable Pattern
All ViewModels must support stub() for testing and SwiftUI previews:
CODEBLOCK13
Identity: vmId
Every ViewModel needs a vmId for SwiftUI's identity system:
Singleton (one per page): vmId = .init(type: Self.self)
Instance (multiple per page): vmId = .init(id: id) where INLINECODE82
Localization
Static UI text uses @LocalizedString:
CODEBLOCK14
With corresponding YAML:
CODEBLOCK15
Dates and Numbers
Never send pre-formatted strings. Use localizable types:
CODEBLOCK16
The client formats these according to user's locale and timezone.
Enum Localization Pattern
For dynamic enum values (status, state, category), use a stored LocalizableString - NOT @LocalizedString.
INLINECODE86 always looks up the same key (the property name). A stored LocalizableString carries the dynamic key from the enum case.
CODEBLOCK17
CODEBLOCK18
Constraint: LocalizableString only works in ViewModels encoded with localizingEncoder(). Do not use in Fluent JSONB fields or other persisted types.
Child ViewModels
Top-level ViewModels contain their children:
CODEBLOCK19
The Factory builds all children when building the parent.
Nested Child Types Pattern
When a child type is only used by one parent and represents a summary or reference (not a full ViewModel), nest it inside the parent:
CODEBLOCK20
Reference: INLINECODE90
Placement rules:
- 1. Nested types go AFTER the properties that reference them
- Before
vmId and the parent's init - Use
// MARK: - Nested Types section marker - Each nested type gets its own doc comment
Conformances for nested types:
- -
Codable - for ViewModel encoding - INLINECODE94 - for Swift 6 concurrency
- INLINECODE95 - for SwiftUI ForEach if used in arrays
- INLINECODE96 - for testing/previews
Two-Tier Stubbable Pattern:
Nested types use fully qualified names in their extensions:
CODEBLOCK21
Why two tiers:
- - Tests often just need
[.stub()] without caring about values - Other tests need specific values: INLINECODE98
- Zero-arg ALWAYS calls parameterized version (single source of truth)
When to nest vs keep top-level:
| Nest Inside Parent | Keep Top-Level |
|---|
| Child is ONLY used by this parent | Child is shared across multiple parents |
| Child represents subset/summary |
Child is a full ViewModel |
| Child has no @ViewModel macro | Child has @ViewModel macro |
| Child is not RequestableViewModel | Child is RequestableViewModel |
| Example: VersionSummary, Reference | Example: CardViewModel, ListViewModel |
Examples:
Card with nested summaries:
CODEBLOCK22
List with nested references:
CODEBLOCK23
Codable and Computed Properties
Swift's synthesized Codable only encodes stored properties. Since ViewModels are serialized (for JSON transport, Leaf rendering, etc.), computed properties won't be available.
CODEBLOCK24
When to pre-compute:
For Leaf templates, you can often use Leaf's built-in functions directly:
- -
#if(count(cards) > 0) - no need for hasCards property - INLINECODE102 - no need for
cardCount property
Pre-compute only when:
- - Direct array subscripts needed (
firstCard - array indexing not documented in Leaf) - Complex logic that's cleaner in Swift than in template
- Performance-sensitive repeated calculations
See fosmvvm-leaf-view-generator for Leaf template patterns.
File Templates
See reference.md for complete file templates.
Naming Conventions
| Concept | Convention | Example |
|---|
| ViewModel struct | INLINECODE105 | INLINECODE106 |
| Request class |
{Name}Request |
DashboardRequest |
| Factory extension |
{Name}ViewModel+Factory.swift |
DashboardViewModel+Factory.swift |
| YAML file |
{Name}ViewModel.yml |
DashboardViewModel.yml |
See Also
Version History
| Version | Date | Changes |
|---|
| 1.0 | 2024-12-24 | Initial skill |
| 2.0 |
2024-12-26 | Complete rewrite from architecture; generalized from Kairos-specific |
| 2.1 | 2024-12-26 | Added Client-Hosted mode support; per-ViewModel hosting decision |
| 2.2 | 2024-12-26 | Added shaping responsibility, @LocalizedSubs/@LocalizedCompoundString, anti-pattern |
| 2.3 | 2025-12-27 | Added Display vs Form ViewModels section; clarified Fields adoption |
| 2.4 | 2026-01-08 | Added Codable/computed properties section. Clarified when to pre-compute vs use Leaf built-ins. |
| 2.5 | 2026-01-19 | Added Enum Localization Pattern section. Clarified @LocalizedString is for static text only; stored LocalizableString for dynamic enum values. |
| 2.6 | 2026-01-24 | Update to context-aware approach (remove file-parsing/Q&A). Skill references conversation context instead of asking questions or accepting file paths. |
| 2.7 | 2026-01-25 | Added Nested Child Types Pattern section with two-tier Stubbable pattern, placement rules, conformances, and decision criteria for when to nest vs keep top-level. |
FOSMVVM ViewModel 生成器
按照 FOSMVVM 架构模式生成 ViewModel。
概念基础
完整架构上下文,请参阅 FOSMVVMArchitecture.md | OpenClaw 参考
ViewModel 是 Model-View-ViewModel 架构中的桥梁:
┌─────────────┐ ┌─────────────────┐ ┌─────────────┐
│ Model │ ───► │ ViewModel │ ───► │ View │
│ (数据) │ │ (桥梁) │ │ (SwiftUI) │
└─────────────┘ └─────────────────┘ └─────────────┘
关键洞察: 在 FOSMVVM 中,ViewModel:
- - 由工厂创建(服务端或客户端)
- 在编码时本地化(解析所有 @LocalizedString 引用)
- 由视图消费,视图仅渲染本地化后的数据
首要决策:托管模式
这是每个 ViewModel 的独立决策。 一个应用可以混合使用两种模式——例如,一个带有基于服务端登录的独立 iPhone 应用。
关键问题:这个 ViewModel 的数据来自哪里?
| 数据来源 | 托管模式 | 工厂 |
|---|
| 服务端/数据库 | 服务端托管 | 手写 |
| 本地状态/偏好设置 |
客户端托管 | 宏生成 |
|
ResponseError(捕获的错误) |
客户端托管 | 宏生成 |
服务端托管模式
当数据来自服务端时:
- - 工厂在服务端手写(ViewModelFactory 协议)
- 工厂查询数据库,构建 ViewModel
- 服务端在 JSON 编码时进行本地化
- 客户端接收完全本地化的 ViewModel
示例: 登录界面、来自 API 的用户资料、带有服务端数据的仪表盘
客户端托管模式
当数据位于设备本地时:
- - 使用 @ViewModel(options: [.clientHostedFactory])
- 宏从初始化参数自动生成工厂
- 客户端打包 YAML 资源
- 客户端在编码时进行本地化
示例: 设置界面、引导页、离线优先功能、错误显示
错误显示模式
错误显示是典型的客户端托管场景。你已经从 ResponseError 获取了数据——只需将其包装到针对该错误的特定 ViewModel 中:
swift
// 针对 MoveIdeaRequest 错误的特定 ViewModel
@ViewModel(options: [.clientHostedFactory])
struct MoveIdeaErrorViewModel {
let message: LocalizableString
let errorCode: String
public var vmId = ViewModelId()
// 接收特定的 ResponseError
init(responseError: MoveIdeaRequest.ResponseError) {
self.message = responseError.message
self.errorCode = responseError.code.rawValue
}
}
使用方式:
swift
catch let error as MoveIdeaRequest.ResponseError {
let vm = MoveIdeaErrorViewModel(responseError: error)
return try await req.view.render(Shared/ToastView, vm)
}
每个错误场景都有其自己的 ViewModel:
- - MoveIdeaErrorViewModel 对应 MoveIdeaRequest.ResponseError
- CreateIdeaErrorViewModel 对应 CreateIdeaRequest.ResponseError
- SettingsValidationErrorViewModel 对应设置表单错误
不要创建通用的 ToastViewModel 或 ErrorViewModel——那是统一错误架构,我们避免这样做。
关键洞察:
- - 无需服务端请求——你已经捕获了错误
- ResponseError 中的 LocalizableString 属性已经本地化(服务端已完成)
- 标准的 ViewModel → 视图编码链能正确处理此情况;已本地化的字符串原样传递
- 客户端托管的 ViewModel 包装现有数据;宏生成工厂
混合应用
许多应用同时使用两种模式:
┌───────────────────────────────────────────────┐
│ iPhone 应用 │
├───────────────────────────────────────────────┤
│ SettingsViewModel → 客户端托管 │
│ OnboardingViewModel → 客户端托管 │
│ MoveIdeaErrorViewModel → 客户端托管 │ ← 错误显示
│ SignInViewModel → 服务端托管 │
│ UserProfileViewModel → 服务端托管 │
└───────────────────────────────────────────────┘
相同的 ViewModel 模式在两种模式下都适用——只有工厂创建方式不同。
核心职责:数据塑形
ViewModel 的职责是为展示塑形数据。这发生在两个地方:
- 1. 工厂——需要什么数据,如何转换
- 本地化——如何在上下文中呈现(包括区域感知排序)
视图仅负责渲染——它绝不应组合、格式化或重新排序 ViewModel 的属性。
ViewModel 包含的内容
ViewModel 回答:视图需要显示什么?
| 内容类型 | 表示方式 | 示例 |
|---|
| 静态 UI 文本 | @LocalizedString | 页面标题、按钮标签(固定文本) |
| 动态枚举值 |
LocalizableString(存储) | 状态/状态显示(参见枚举本地化模式) |
| 文本中的动态数据 | @LocalizedSubs | 欢迎,%{name}! 带替换 |
| 组合文本 | @LocalizedCompoundString | 由片段组成的全名(区域感知顺序) |
| 格式化日期 | LocalizableDate | createdAt: LocalizableDate |
| 格式化数字 | LocalizableInt | totalCount: LocalizableInt |
| 动态数据 | 普通属性 | content: String,count: Int |
| 嵌套组件 | 子 ViewModel | cards: [CardViewModel] |
ViewModel 不包含的内容
- - 数据库关系(@Parent,@Siblings)
- 业务逻辑或验证(这些在 Fields 协议中)
- 暴露给模板的原始数据库 ID(使用类型化属性)
- 视图必须查找的未本地化字符串
反模式:在视图中组合
swift
// ❌ 错误 - 视图在组合
Text(viewModel.firstName) + Text( ) + Text(viewModel.lastName)
// ✅ 正确 - ViewModel 提供塑形后的结果
Text(viewModel.fullName) // 通过 @LocalizedCompoundString
如果在视图中看到 + 或字符串插值,塑形工作应属于 ViewModel。
ViewModel 协议层级
swift
public protocol ViewModel: ServerRequestBody, RetrievablePropertyNames, Identifiable, Stubbable {
var vmId: ViewModelId { get }
}
public protocol RequestableViewModel: ViewModel {
associatedtype Request: ViewModelRequest
}
ViewModel 提供:
- - ServerRequestBody - 可通过 HTTP 以 JSON 形式发送
- RetrievablePropertyNames - 启用 @LocalizedString 绑定(通过 @ViewModel 宏)
- Identifiable - 为 SwiftUI 提供 vmId 标识
- Stubbable - 提供 stub() 用于测试/预览
RequestableViewModel 额外提供:
- - 关联的 Request 类型,用于从服务端获取数据
两类 ViewModel
1. 顶层(RequestableViewModel)
代表完整页面或界面。具有:
- - 关联的 ViewModelRequest 类型
- 从数据库构建它的 ViewModelFactory
- 嵌入其中的子 ViewModel
swift
@ViewModel
public struct DashboardViewModel: RequestableViewModel {
public typealias Request = DashboardRequest
@LocalizedString public var pageTitle
public let cards: [CardViewModel] // 子组件
public var vmId: ViewModelId = .init()
}
2. 子级(普通 ViewModel)
由其父级工厂构建的嵌套组件。无需 Request 类型。
swift
@ViewModel
public struct CardViewModel: Codable, Sendable {
public let id: ModelIdType
public let title: String
public let createdAt: LocalizableDate
public var vmId: ViewModelId = .init()
}
展示型与表单型 ViewModel
ViewModel 服务于两个不同的目的:
| 目的 | ViewModel 类型 | 采用 Fields? |
|---|
| 展示数据(只读) | 展示型 ViewModel | 否 |
| 收集用户输入(可编辑) |
表单型 ViewModel | 是 |
展示型 ViewModel
用于显示数据——卡片、行、列表、详情视图:
swift
@ViewModel
public struct UserCardViewModel {
public