{"id":"fission-tabs-1","tabs":[{"index":0,"label":"Compose","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-1","index":0,"label":"Compose","value":"source"}
@Composable
fun TodoScreen() {
val todos = listOf(
Todo(1, "Write release notes", false),
Todo(2, "Test Android package", true),
)
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Ship checklist", style = MaterialTheme.typography.headlineMedium)
todos.forEach { todo ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(if (todo.done) "Done" else "Open")
Text(todo.title)
}
}
}
}
{"id":"fission-tabs-1","index":1,"label":"Fission","value":"fission"}
use fission::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Todo {
pub id: u64,
pub title: String,
pub done: bool,
}
pub struct TodoApp;
impl From<TodoApp> for Widget {
fn from(_app: TodoApp) -> Widget {
let todos = vec![
Todo { id: 1, title: "Write release notes".into(), done: false },
Todo { id: 2, title: "Test Android package".into(), done: true },
];
let rows = todos.into_iter().map(|todo| {
let status = if todo.done { "Done" } else { "Open" };
Row {
gap: Some(12.0),
children: widgets![Text::new(status), Text::new(todo.title)],
..Default::default()
}
.into()
});
Container::new(Column {
gap: Some(12.0),
children: std::iter::once(Text::new("Ship checklist").size(28.0).weight(800).into())
.chain(rows)
.collect(),
..Default::default()
})
.padding_all(24.0)
.into()
}
}
{"id":"fission-tabs-1"}
{"id":"fission-tabs-2","tabs":[{"index":0,"label":"Compose","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-2","index":0,"label":"Compose","value":"source"}
@Composable
fun TodoScreen(viewModel: TodoViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
TodoContent(
state = state,
onDraftChanged = viewModel::setDraft,
onAdd = viewModel::addDraftTodo,
onToggle = viewModel::toggleTodo,
)
}
{"id":"fission-tabs-2","index":1,"label":"Fission","value":"fission"}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TodoState {
pub draft: String,
pub todos: Vec<Todo>,
pub next_id: u64,
pub current_path: String,
}
impl Default for TodoState {
fn default() -> Self {
Self {
draft: String::new(),
todos: Vec::new(),
next_id: 1,
current_path: "/".into(),
}
}
}
impl GlobalState for TodoState {}
#[fission_reducer(SetDraft)]
fn set_draft(state: &mut TodoState, draft: String) {
state.draft = draft;
}
#[fission_reducer(AddDraftTodo)]
fn add_draft_todo(state: &mut TodoState) {
let title = state.draft.trim();
if title.is_empty() {
return;
}
state.todos.push(Todo {
id: state.next_id,
title: title.to_string(),
done: false,
});
state.next_id += 1;
state.draft.clear();
}
#[fission_reducer(ToggleTodo)]
fn toggle_todo(state: &mut TodoState, id: u64) {
if let Some(todo) = state.todos.iter_mut().find(|todo| todo.id == id) {
todo.done = !todo.done;
}
}
pub struct TodoScreen;
impl From<TodoScreen> for Widget {
fn from(_screen: TodoScreen) -> Widget {
let (ctx, view) = fission::build::current::<TodoState>();
let update_draft = with_reducer!(ctx, SetDraft(String::new()), set_draft);
let add_todo = with_reducer!(ctx, AddDraftTodo, add_draft_todo);
let rows = view.state().todos.iter().map(|todo| {
let toggle = with_reducer!(ctx, ToggleTodo(todo.id), toggle_todo);
Row {
gap: Some(12.0),
children: widgets![
Checkbox {
checked: todo.done,
on_toggle: Some(toggle),
..Default::default()
}
.semantics_identifier(format!("todo.toggle.{}", todo.id)),
Text::new(todo.title.clone()),
],
..Default::default()
}
.semantics_identifier(format!("todo.row.{}", todo.id))
.into()
});
Column {
gap: Some(16.0),
children: widgets![
Text::new("Ship checklist").size(28.0).weight(800),
Row {
gap: Some(12.0),
children: widgets![
TextInput {
id: Some(WidgetId::explicit("todo-title")),
value: view.state().draft.clone(),
label: Some("New task".into()),
placeholder: Some("Add a task".into()),
on_change: Some(update_draft),
..Default::default()
}
.semantics_identifier("todo.title"),
Button {
child: Some(Text::new("Add task").into()),
on_press: Some(add_todo),
..Default::default()
}
.semantics_identifier("todo.add"),
],
..Default::default()
},
Column { gap: Some(10.0), children: rows.collect(), ..Default::default() },
],
..Default::default()
}
.into()
}
}
{"id":"fission-tabs-2"}
{"id":"fission-tabs-3","tabs":[{"index":0,"label":"Compose","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-3","index":0,"label":"Compose","value":"source"}
LaunchedEffect(Unit) {
viewModel.loadTodos()
}
class TodoViewModel : ViewModel() {
fun loadTodos() = viewModelScope.launch {
_state.update { it.copy(loading = true) }
_state.update { it.copy(todos = repository.loadTodos(), loading = false) }
}
}
{"id":"fission-tabs-3","index":1,"label":"Fission","value":"fission"}
use fission::core::{JobRef, JobSpec};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LoadTodosRequest;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApiError {
pub message: String,
}
pub struct LoadTodosJob;
impl JobSpec for LoadTodosJob {
type Request = LoadTodosRequest;
type Ok = Vec<Todo>;
type Err = ApiError;
const NAME: &'static str = "todo.load";
}
pub const LOAD_TODOS_JOB: JobRef<LoadTodosJob> = JobRef::new(LoadTodosJob::NAME);
#[fission_reducer(LoadTodos)]
fn load_todos(state: &mut TodoState, ctx: &mut ReducerContext<TodoState>) {
state.loading = true;
ctx.effects
.app(LOAD_TODOS_JOB, LoadTodosRequest)
.on_ok(ctx.effects.bind(TodosLoaded, reduce_with!(todos_loaded)))
.on_err(ctx.effects.bind(TodosFailed, reduce_with!(todos_failed)));
}
{"id":"fission-tabs-3"}
{"id":"fission-tabs-4","tabs":[{"index":0,"label":"Compose","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-4","index":0,"label":"Compose","value":"source"}
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp,
) {
Row(Modifier.padding(16.dp)) {
Checkbox(checked = todo.done, onCheckedChange = { onToggle(todo.id) })
Text(todo.title, style = MaterialTheme.typography.bodyMedium)
}
}
{"id":"fission-tabs-4","index":1,"label":"Fission","value":"fission"}
pub struct TodoRow {
pub todo: Todo,
}
impl From<TodoRow> for Widget {
fn from(row: TodoRow) -> Widget {
let (ctx, view) = fission::build::current::<TodoState>();
let tokens = &view.env().theme.tokens;
let toggle = with_reducer!(ctx, ToggleTodo(row.todo.id), toggle_todo);
Container::new(Row {
gap: Some(tokens.spacing.s),
children: widgets![
Checkbox {
checked: row.todo.done,
on_toggle: Some(toggle),
..Default::default()
}
.semantics_identifier(format!("todo.toggle.{}", row.todo.id)),
Text::new(row.todo.title)
.size(tokens.typography.font_size_base)
.color(tokens.colors.text_primary),
],
..Default::default()
})
.bg(tokens.colors.surface)
.border(tokens.colors.border, 1.0)
.border_radius(tokens.radii.large)
.padding([tokens.spacing.m, tokens.spacing.m, tokens.spacing.s, tokens.spacing.s])
.into()
}
}
{"id":"fission-tabs-4"}
{"id":"fission-tabs-5","tabs":[{"index":0,"label":"Compose","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-5","index":0,"label":"Compose","value":"source"}
NavHost(navController, startDestination = "todos") {
composable("todos") { TodoScreen() }
composable("todos/{id}") { entry ->
TodoDetail(entry.arguments?.getString("id")?.toLong() ?: 0)
}
}
{"id":"fission-tabs-5","index":1,"label":"Fission","value":"fission"}
use fission::widgets::{Route, Router};
use std::sync::Arc;
#[fission_reducer(NavigateTo)]
fn navigate_to(state: &mut TodoState, path: String) {
state.current_path = path;
}
pub struct TodoRoutes;
impl From<TodoRoutes> for Widget {
fn from(_routes: TodoRoutes) -> Widget {
let (_, view) = fission::build::current::<TodoState>();
Router::<TodoState> {
current_path: view.state().current_path.clone(),
routes: vec![
Route {
path: "/".into(),
builder: Arc::new(|_, _, _| TodoScreen.into()),
},
Route {
path: "/todos/:id".into(),
builder: Arc::new(|_, _, params| TodoDetail {
id: params["id"].parse().unwrap_or(0),
}
.into()),
},
],
not_found: Some(Arc::new(|_, _, _| Text::new("Not found").into())),
}
.into()
}
}
{"id":"fission-tabs-5"}
{"id":"fission-tabs-6","tabs":[{"index":0,"label":"Compose","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-6","index":0,"label":"Compose","value":"source"}
composeTestRule.setContent { TodoScreen() }
composeTestRule.onNodeWithText("New task").performTextInput("Publish release")
composeTestRule.onNodeWithText("Add task").performClick()
composeTestRule.onNodeWithText("Publish release").assertIsDisplayed()
{"id":"fission-tabs-6","index":1,"label":"Fission","value":"fission"}
use fission_test_driver::{LiveTestClient, SelectorQuery};
#[test]
#[ignore]
fn user_can_add_a_todo_in_a_real_shell() {
let control_port = reserve_control_port();
let mut child = launch_todo_app(control_port);
let client = LiveTestClient::connect(control_port);
client.wait_for_ready(15_000).expect("app ready");
client
.fill_text_semantic_identifier("todo.title", "Publish release")
.expect("fill draft");
client
.tap_semantic_identifier("todo.add")
.expect("add todo");
client
.wait_for_visible(SelectorQuery::label("Publish release"), 5_000)
.expect("todo visible");
client
.screenshot(".artifacts/screenshots/todos/added.png")
.expect("screenshot");
client.quit().expect("quit app");
let _ = child.wait();
}
{"id":"fission-tabs-6"}
Compose concept | Closest Fission concept | Important difference |
|---|---|---|
@Composable | Rust struct plus impl From<T> for Widget | Fission conversion is not a suspending/composable function. |
remember | #[local_state] | Retained by widget identity and updated through local reducers. |
ViewModel state | GlobalState | Product state is the framework-level app model. |
LaunchedEffect | Jobs/resources/effects | Outside work is requested outside conversion. |
MaterialTheme | Generated design-system tokens in Env | Theme is target-lowerable runtime data. |
NavHost | Fission Router | Route state is app-owned. |