cat swift/milestone-interfaccia-swiftui-cli.md
Milestone 6: interfaccia SwiftUI o CLI
Aggiungere una consegna visibile al progetto finale: piccola app SwiftUI o CLI ergonomica costruita sopra lo stesso nucleo testabile.
Il progetto finale deve avere una superficie usabile.
Puoi scegliere:
- piccola app SwiftUI;
- CLI;
- endpoint Vapor;
- SDK con esempio eseguibile.
In questa milestone ci concentriamo sulle due opzioni più pratiche: SwiftUI e CLI.
Regola principale
L’interfaccia non deve contenere il dominio.
Deve:
- raccogliere input;
- chiamare use case;
- mostrare stato;
- tradurre errori;
- gestire lifecycle o exit code.
Non deve:
- costruire URL;
- decodificare JSON remoto;
- duplicare regole;
- decidere invarianti;
- accedere direttamente a cache interna.
Opzione A: SwiftUI
Stato:
public enum LoadingState<Value: Equatable>: Equatable {
case idle
case loading
case loaded(Value)
case failed(String)
}
View model:
@MainActor
@Observable
final class StoryListViewModel {
private let searchStories: SearchStoriesUseCase
var query = ""
var state: LoadingState<[Story]> = .idle
init(searchStories: SearchStoriesUseCase) {
self.searchStories = searchStories
}
func search() async {
do {
state = .loading
let searchQuery = try SearchQuery(query)
let stories = try await searchStories.execute(query: searchQuery)
state = .loaded(stories)
} catch {
state = .failed(Self.message(for: error))
}
}
private static func message(for error: Error) -> String {
switch error {
case SearchError.queryTooShort:
return "Inserisci almeno due caratteri."
default:
return "Impossibile caricare i risultati."
}
}
}
La view model è il punto testabile della UI.
SwiftUI screen
struct StorySearchScreen: View {
@State private var viewModel: StoryListViewModel
init(viewModel: StoryListViewModel) {
self._viewModel = State(initialValue: viewModel)
}
var body: some View {
NavigationStack {
List {
Section {
TextField("Cerca", text: $viewModel.query)
.textInputAutocapitalization(.never)
Button("Cerca") {
Task {
await viewModel.search()
}
}
}
content
}
.navigationTitle("Stories")
}
}
@ViewBuilder
private var content: some View {
switch viewModel.state {
case .idle:
EmptyView()
case .loading:
ProgressView()
case .loaded(let stories):
ForEach(stories) { story in
Text(story.title)
}
case .failed(let message):
Text(message)
}
}
}
Per il capstone basta una UI piccola ma coerente.
SwiftUI composition
extension StoryListViewModel {
static func live() -> StoryListViewModel {
let client = URLSessionHTTPClient()
let repository = RemoteStoryRepository(
client: client,
requestBuilder: RequestBuilder(baseURL: URL(string: "https://hn.algolia.com")!)
)
return StoryListViewModel(
searchStories: SearchStoriesUseCase(repository: repository)
)
}
}
In un progetto più grande, sposta questa factory in un composition root.
Test view model
@MainActor
@Test
func viewModelMostraRisultati() async throws {
let viewModel = StoryListViewModel(
searchStories: SearchStoriesUseCase(
repository: InMemoryStoryRepository(stories: [.sample])
)
)
viewModel.query = "swift"
await viewModel.search()
#expect(viewModel.state == .loaded([.sample]))
}
La view grafica può restare fuori dalla test suite del capstone, se il view model è ben coperto.
Opzione B: CLI
CLI con ArgumentParser:
@main
struct StoryCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "story",
subcommands: [Search.self]
)
}
Search:
struct Search: AsyncParsableCommand {
@Argument(help: "Termine di ricerca.")
var query: String
@Flag(help: "Stampa output JSON.")
var json = false
mutating func run() async throws {
let environment = try CLIEnvironment.live()
let stories = try await environment.search.execute(
query: try SearchQuery(query)
)
if json {
try environment.output.writeJSON(stories)
} else {
stories.forEach { environment.output.write($0.title) }
}
}
}
La CLI deve essere comoda anche in automazioni.
OutputWriter
protocol OutputWriter: Sendable {
func write(_ line: String)
}
struct ConsoleOutputWriter: OutputWriter {
func write(_ line: String) {
print(line)
}
}
Per output JSON:
extension OutputWriter {
func writeJSON<T: Encodable>(_ value: T) throws {
let data = try JSONEncoder().encode(value)
write(String(decoding: data, as: UTF8.self))
}
}
Errori CLI
Traduci errori in messaggi.
catch SearchError.queryTooShort {
throw ValidationError("La query deve avere almeno due caratteri.")
}
Una CLI deve fallire in modo comprensibile.
Checklist
L’interfaccia è completa quando:
- usa use case, non infrastruttura diretta;
- mostra loading/success/error;
- traduce errori;
- è testabile tramite view model o command wrapper;
- ha una demo chiara;
- non duplica regole di dominio;
- supporta cancellation se usa SwiftUI task;
- ha output stabile se è CLI.
Esercizio
Scegli una superficie:
SwiftUI
- view model
@MainActor; - stato
idle/loading/loaded/failed; - schermata con input e risultati;
- factory live;
- test view model.
CLI
- executable target;
- comando principale;
- almeno un subcommand;
- output plain text;
- opzione
--json; - test output.
Obiettivo: il progetto deve essere dimostrabile in meno di un minuto.