fosmvvm-viewmodel-generator
为 SwiftUI 屏幕、页面和组件生成 FOSMVVM ViewModel。支架 RequestableViewModel、本地化绑定和存根工厂。
安装 / 下载方式
TotalClaw CLI推荐
totalclaw install totalclaw:totalclaw~foscomputerservices-fosmvvm-viewmodel-generatorcURL直接下载,无需登录
curl -fsSL https://skills.taituai.com/api/skills/totalclaw%3Atotalclaw~foscomputerservices-fosmvvm-viewmodel-generator/file -o foscomputerservices-fosmvvm-viewmodel-generator.md## 概述(中文)
为 SwiftUI 屏幕、页面和组件生成 FOSMVVM ViewModel。支架 RequestableViewModel、本地化绑定和存根工厂。
## 原文
# FOSMVVM ViewModel Generator
Generate ViewModels following FOSMVVM architecture patterns.
## Conceptual Foundation
> For full architecture context, see [FOSMVVMArchitecture.md](../../docs/FOSMVVMArchitecture.md) | [OpenClaw reference]({baseDir}/references/FOSMVVMArchitecture.md)
A **ViewModel** is the bridge in the Model-View-ViewModel architecture:
```
┌─────────────┐ ┌─────────────────┐ ┌─────────────┐
│ Model │ ───► │ ViewModel │ ───► │ View │
│ (Data) │ │ (The Bridge) │ │ (SwiftUI) │
└─────────────┘ └─────────────────┘ └─────────────┘
```
**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 `@ViewModel(options: [.clientHostedFactory])`
- 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:
```swift
// Specific ViewModel for MoveIdeaRequest errors
@ViewModel(options: [.clientHostedFactory])
struct MoveIdeaErrorViewModel {
let message: LocalizableString
let errorCode: String
public var vmId = ViewModelId()
// Takes the specific ResponseError
init(responseError: MoveIdeaRequest.ResponseError) {
self.message = responseError.message
self.errorCode = responseError.code.rawValue
}
}
```
Usage:
```swift
catch let error as MoveIdeaRequest.ResponseError {
let vm = MoveIdeaErrorViewModel(responseError: error)
return try await req.view.render("Shared/ToastView", vm)
}
```
**Each error scenario gets its own ViewModel:**
- `MoveIdeaErrorViewModel` for `MoveIdeaRequest.ResponseError`
- `CreateIdeaErrorViewModel` for `CreateIdeaRequest.ResponseError`
- `SettingsValidationErrorViewModel` 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:
```
┌───────────────────────────────────────────────┐
│ iPhone App │
├───────────────────────────────────────────────┤
│ SettingsViewModel → Client-Hosted │
│ OnboardingViewModel → Client-Hosted │
│ MoveIdeaErrorViewModel → Client-Hosted │ ← Error display
│ SignInViewModel → Server-Hosted │
│ UserProfileViewModel → Server-Hosted │
└───────────────────────────────────────────────┘
```
**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
2. **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 | `@LocalizedString` | 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
```swift
// ❌ WRONG - View is composing
Text(viewModel.firstName) + Text(" ") + Text(viewModel.lastName)
// ✅ RIGHT - ViewModel provides shaped result
Text(viewModel.fullName) // via @LocalizedCompoundString
```
If you see `+` or string interpolation in a View, the shaping belongs in the ViewModel.
## ViewModel Protocol Hierarchy
```swift
public protocol ViewModel: ServerRequestBody, RetrievablePropertyNames, Identifiable, Stubbable {
var vmId: ViewModelId { get }
}
public protocol RequestableViewModel: ViewModel {
associatedtype Request: ViewModelRequest
}
```
**ViewModel** provides:
- `ServerRequestBody` - Can be sent over HTTP as JSON
- `RetrievablePropertyNames` - Enables `@LocalizedString` binding (via `@ViewModel` macro)
- `Identifiable` - Has `vmId` for SwiftUI identity
- `Stubbable` - 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
```swift
@ViewModel
public struct DashboardViewModel: RequestableViewModel {
public typealias Request = DashboardRequest
@LocalizedString public var pageTitle
public let cards: [CardViewModel] // Children
public var vmId: ViewModelId = .init()
}
```
### 2. Child (plain ViewModel)
Nested components built by their parent's factory. No Request type.
```swift
@ViewModel
public struct CardViewModel: Codable, Sendable {
public let id: ModelIdType
public let title: String
public let createdAt: LocalizableDate
public var vmId: ViewModelId = .init()
}
```
---
## 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:
```swift
@ViewModel
public struct UserCardViewModel {
public let id: ModelIdType