Structuring code in a multi-page Crux app

Sean Borg February 19, 2025

Welcome to how I structure a multi-page application using the Elm architecture in the Crux framework. Writing this because I found a lot of single page app examples but haven't come across how people handle multiple pages.

All code examples come from my UpLine app (specifically from the blog-post branch)

The first 4 things is how I structure the core (Rust), 5 is then the shell (Android) side. (p.s. I don't explain Crux at all, I am assuming your familiar with it. I would recommend the design overview as a quick way to understand this blog)

  1. Starting out with the data types I structure The Model
  2. Then continuing with the types I structure the Events
  3. Finally jumping into The Code and show how the above heavily influence the structure
  4. A quick look at how I organised the UiModel
  5. Lastly how the UiModel influences the Android code structure

The model

The internal data struct

Top level Model

#[derive(Default, Clone, Debug)]
pub struct Model {
    pub screen: Screens,
    pub user_messages: UserMessages,
    pub example_data: ExampleData,
}

Above we have the Screens type, which hides all the magic, then anything I want persistent across screens I need to also keep at this level so in my case user_messages and example_data we keep out of Screens

Model.screens

The magic... A massive enum :D

// order is alphabetical to be in line with file browser  
#[derive(Clone, Debug)]
pub enum Screens {
    InitialScreen(InitialScreenData),
    InputFieldsTagsScreen(InputFieldsAndTagsScreenData),
    PostWriteReviewScreen(PostWriteReviewScreenData),
    SelectOrgBucketMeasurementScreen(Box<SelectOrgBucketMeasurementScreenData>),
    SettingPreviousConnectionsScreen(SettingsPreviousConnectionsActionsScreenData),
    SettingsScreen,
}

With Rust allowing enum variants to hold internal data I make a variation per screen appended with Screen and create a new data type per screen appended with ScreenData, the naming scheme was helpful for myself to keep track of what I have where.

Why the box?

Very simply clippy or the compiler suggested it once and I went with it, at some point I should review and maybe convert others. The reason was about the enum data types being wildly different sizes.

Moving screens

Originally thought moving screens would be problematic/annoying but using Rust's strong typing we can get some nice wins from this. First is we can take unvalidated user input held as option<String> and validate it converting it to just a String or another strong type, simplifying logic on later screens. We can see this between my first 2 screens initial>SelectOrgBucketMeasurement.

Image displaying 2 structures and a try_from impl block

Secondly we can leverage TryFrom quite nicely so we can do ~ let screen2: Screen2data = screen1data.tryinto() allowing us to easily move to screen2 with the Err giving us a nice message to relay back to the user

Events

Top level Events

Similar to Model with Event we can associate data with the enums, this time though we are just wrapping up more enums as sub events per screen, so...

// order is alphabetical to be in line with file browser
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum Event {
    EvInitialScreen(ScEvInitial),
    EvInputFieldsAndTagsEventsScreen(ScEvInputFieldsAndTags),
    EvPostWriteReviewScreen(ScEvPostWriteReview),
    EvSettingsScreen(ScEvSettings),
    EvSelectOrgBucketMeasurementScreen(ScEvSelectOrgBucketMeasurement),
    EvSettingsPreviousConnectionsScreen(ScEvSettingPreviousConnections),
    EvGlobal(GlEvGlobal),
}

As the Event type crosses the FFI boundary I found it much nicer for type completion in Kotlin to prepend the Ev for event variation and ScEv for the internal data on each event variation.

Screen event

Internally the screen events

// order is alphabetical to be in line with file browser
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub enum ScEvInitial {
    AppendPreviousConnection(PreviousConnection),
    #[serde(skip)]
    AppendPreviousConnectionResult(PersistentStorageOutput),
    InfluxBucketsConnectionTest,
    #[serde(skip)]
    InfluxBucketsConnectionTestResponse(crux_http::Result<crux_http::Response<BucketsResult>>),
    LoadPreviousConnections,
    #[serde(skip)]
    LoadPreviousConnectionsResult(PersistentStorageOutput),
    QuickActionsLoad,
    #[serde(skip)]
    QuickActionsLoadResult(PersistentStorageOutput),
    QuickActionsUserSelected(UiStringIndex),
    LoadSettingsScreen,
    SetApiToken(String),
    SetBaseUrl(String),
    UserSelectedPreviousConnection(UiStringIndex),
}

Above is all the events I have on the initial screen, this does make calling an event in Android quite a long as we need to specify screen then event e.g.

core.update(
  Event.EvInitialScreen(
    ScEvInitial.LoadPreviousConnections()
  )
)

but I found I can make a simple function to reduce that

fun screenEvent(core: Core, coroutineScope: CoroutineScope, event: ScEvInitial) {
    coroutineScope.launch {
        core.update(
            Event.EvInitialScreen(
                event
            )
        )
    }
}

// now calls look like
screenEvent(core, coroutineScope, LoadPreviousConnections())

Global Events

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub enum GlEvGlobal {
    UpdateTutorial(u8),
    BackButtonClicked,
}

Originally I had global events in the top level but figured it was just nicer to wrap them up in another enum so they have a nice place to grow. 🌱

The Code

app.rs (Rust entry point)

First the Event enum and how that influences the code structure.

Image displaying Events enum in app.rs file and how tha alphabetical ordering maps to the filesystem

With the above picture we can see why I ordered things alphabetically, because for each ScEv<name> I have made a folder screens/<name>/mod.rs. Keeping it alphabetical helps with easily jumping between the enum folder and vice versa.

Top level update function

Now all my top level Events are really a group of screen events my top level update function, is just passing the event onto a function in the mod.rs of the corresponding folder e.g. event of type EvInitialScreen will call a function in screens/initial/mod.rs, I took the naming scheme of _ screen_events so we get initial_screen_events

fn update(&self, event: Self::Event, model: &mut Self::Model, _caps: &Self::Capabilities, )
          -> Command<Effect, Event> {
    // ...
    match event {
        InitialScreenEvents(internal_event) => initial_screen_events(internal_event, model),
        // ...
    }
}
Full update function at time of writing

The actuality of the function has a bit of global setup, so for me setting up android logging and some messing with the user_messages. But after all that we have the same match statement and keeping the alphabetical order to help find things, so as long as we don't have a semicolon at the end we can quite quickly verify that all the return commands are actually coming from screen/<name>/mod.rs

fn update(&self, event: Self::Event, model: &mut Self::Model, _caps: &Self::Capabilities,
) -> Command<Effect, Event> {
    START.call_once(|| {
        android_logger::init_once(
            Config::default()
                .with_max_level(log::LevelFilter::Warn)
                .with_tag("FL:C"),
        );
    });

    trace!("Clear all existing messages");
    model.user_messages.remove_all_messages();

    trace!("Run event");
    trace!("model:{:?}", model.screen);
    trace!("event:{:?}", event);
    match event {
        InitialScreenEvents(internal_event) => initial_screen_events(internal_event, model),
        SelectOrgBucketMeasurementScreenEvents(internal_event) => {
            select_org_bucket_measurement_screen_events(internal_event, model)
        }
        InputFieldsAndTagsEventsScreenEvents(internal_event) => {
            input_fields_and_tags_events(internal_event, model)
        }
        SettingsScreenEvents(internal_event) => settings_events(internal_event, model),
        SettingsPreviousConnectionsScreenEvents(internal_event) => {
            setting_previous_connections_events(internal_event, model)
        }
        PostWriteReviewScreenEvents(event) => post_write_review_screen_events(event, model),
        GlobalEvent(event) => global_events(event, model),
    }
}

Screen mod.rs

Events / update function

We shall get to why mod.rs has 400 lines before we get to the only function in the file, but here is one of ourscreen/<name>/mod.rs::<name>_screen_events functions.

Image showing how a screen enum maps to the fie system when matching

First now we are one level down we can get the screen data out of the model, sadly this can't be a thing we can assert at build time but in general the folder structure makes this easy to maintain. This allows us to pass sreenData to our functions reducing scope the functions can effect and saving dealing with repeatedly asserting the screen model.

In general, we have repeated the same trick we had in app.rs now in screen/<name>/mod.rs this time all our events correspond to a file. The enum variant / file name / function all have the same name (within naming conventions) e.g. variant BackButtonPressed() / file name back_button_pressed.rs / function fn back_button_pressed() {}.

Again keeping alphabetical order to make things easier to find.

My full `function` at time of writing
pub fn input_fields_and_tags_events(
    initial_screen_event: InputFieldsAndTagsScreenEventsData,
    model: &mut Model,
) -> Command<Effect, Event> {
    trace!("input_fields_and_tags screen event: Validating model");

    let Screens::InputFieldsTagsScreen(ref mut screen_data) = model.screen else {
        error!("Screen was in an unexpected state {:?}", model.screen);
        model.user_messages.error_report_to_developer(N065_USED);
        return crux_core::render::render();
    };

    trace!("Running input fields and tags event");

    match initial_screen_event {
        InputFieldsAndTagsScreenEventsData::BackButtonPressed => {
            back_button_pressed(&mut model.screen, &mut model.user_messages)
        }

        InputFieldsAndTagsScreenEventsData::FieldExistingAdd(field) => {
            field_existing_add(screen_data, field, &mut model.user_messages)
        }
        InputFieldsAndTagsScreenEventsData::FieldExistingRemove(field) => {
            field_existing_remove(screen_data, field)
        }
        InputFieldsAndTagsScreenEventsData::FieldExistingValueSet(key, value) => {
            field_existing_value_set(key, value, screen_data, &mut model.user_messages)
        }
        InputFieldsAndTagsScreenEventsData::FluxGetMeasurementsExistingFields => {
            flux_get_measurements_existing_fields(
                screen_data,
                &mut model.user_messages,
                &model.example_data,
            )
        }
        InputFieldsAndTagsScreenEventsData::FluxGetMeasurementsExistingFieldsResponse(response) => {
            flux_get_measurements_existing_fields_response(
                screen_data,
                response,
                &mut model.user_messages,
                &model.example_data,
            )
        }
        InputFieldsAndTagsScreenEventsData::FluxGetMeasurementsExistingTags => {
            flux_get_measurements_existing_tags(
                screen_data,
                &mut model.user_messages,
                &model.example_data,
            )
        }
        InputFieldsAndTagsScreenEventsData::FluxGetMeasurementsExistingTagsResponse(response) => {
            flux_get_measurements_existing_tags_response(
                screen_data,
                response,
                &mut model.user_messages,
                &model.example_data,
            )
        }
        InputFieldsAndTagsScreenEventsData::FluxGetTagsExistingValues => Command::done(),
        InputFieldsAndTagsScreenEventsData::FluxGetTagsExistingValuesResponse(response, tag) => {
            flux_get_tags_existing_values_response(
                screen_data,
                response,
                &mut model.user_messages,
                tag,
            )
        }
        InputFieldsAndTagsScreenEventsData::InfluxWrite => influx_write(
            &mut model.screen,
            &mut model.user_messages,
            &model.example_data,
        ),
        InputFieldsAndTagsScreenEventsData::InfluxWriteResponse(response) => {
            influx_write_response(response, model)
        }
        InputFieldsAndTagsScreenEventsData::LineProtocolSubmitLine => Command::done(),
        InputFieldsAndTagsScreenEventsData::TagExistingAdd(tag) => tag_existing_add(
            screen_data,
            &mut model.user_messages,
            tag,
            &model.example_data,
        ),
        InputFieldsAndTagsScreenEventsData::TagExistingRemove(tag) => {
            tag_existing_remove(screen_data, tag)
        }
        InputFieldsAndTagsScreenEventsData::TagExistingValueAcceptModeChange(key) => {
            tag_existing_value_accept_mode_change(key, screen_data, &mut model.user_messages)
        }
        InputFieldsAndTagsScreenEventsData::TagExistingValueDismissModeChange(key) => {
            tag_existing_value_dismiss_mode_change(key, screen_data, &mut model.user_messages)
        }
        InputFieldsAndTagsScreenEventsData::TagExistingValueSetIndex(key, value) => {
            tag_existing_value_set_index(key, value, screen_data, &mut model.user_messages)
        }
        InputFieldsAndTagsScreenEventsData::TagExistingValueSetUser(key, value) => {
            tag_existing_value_set_user(key, value, screen_data, &mut model.user_messages)
        }
        InputFieldsAndTagsScreenEventsData::TagNewAdd() => {
            tag_new_add(screen_data, &mut model.user_messages)
        }
        InputFieldsAndTagsScreenEventsData::TagNewKaySet(string_index, key) => {
            tag_new_key_set(string_index, key, screen_data, &mut model.user_messages)
        }
        InputFieldsAndTagsScreenEventsData::TagNewRemove(string_index) => {
            tag_new_remove(string_index, screen_data, &mut model.user_messages)
        }
        InputFieldsAndTagsScreenEventsData::TagNewValueSet(string_index, value) => {
            tag_new_value_set(string_index, value, screen_data, &mut model.user_messages)
        }
        InputFieldsAndTagsScreenEventsData::TimeSet(time) => time_set(time, screen_data),
        InputFieldsAndTagsScreenEventsData::FieldNewAdd => {
            field_new_add(screen_data, &mut model.user_messages)
        }
        InputFieldsAndTagsScreenEventsData::FieldNewRemove(string_index) => {
            field_new_remove(string_index, screen_data, &mut model.user_messages)
        }
        InputFieldsAndTagsScreenEventsData::FieldNewValueSet(string_index, new_value) => {
            field_new_value_set(
                new_value,
                string_index,
                screen_data,
                &mut model.user_messages,
            )
        }
        InputFieldsAndTagsScreenEventsData::FieldNewKeySet(string_index, new_key) => {
            field_new_key_set(new_key, string_index, screen_data, &mut model.user_messages)
        }
        InputFieldsAndTagsScreenEventsData::FieldNewChangeType(string_index, new_type) => {
            field_new_change_type(
                new_type,
                string_index,
                screen_data,
                &mut model.user_messages,
            )
        }
    }
}

We can see, although it gets bigger so long as im not terminating the match with a semicolon Rust will guarantee we are passing the output of each match arm as our output to the function

Screen Events & Model

After watching both the Events enum and Model struct grow in their respective top level files I decided to pull them into the screen/<name>/mod.rs as well. This made a lot of sense as mod.rs is the entry point for the file it is now also where the Events and Data is defined, keeping the majority of my work on a screen in 1 folder. Downside mod.rs gets very big, my order was:

which is how the update function ends up 400 lines down :D

Image showing the screen unum and how it matches to the filesystem by being alphabetically ordered

Image of a screen model and a hightlighting of the try from for the previous screen

In the images above we see the alphabetical ordering again, and the tryFrom mentioned earlier on.

Full file

Function files

Tiny subset of the function files to see how everything works.

Load quick actions

In the bellow we have an example of a very small function but this is great to show the testing of the commands

pub fn load_quick_actions() -> Command<Effect, Event> {
    trace!("load_quick_actions called");

    persistent_storage::load(QUICK_ACTIONS_FILENAME.to_owned()).then_send(construct_response_event)
}
fn construct_response_event(body: PersistentStorageOutput) -> Event {
    EvInitialScreen(ScEvInitial::QuickActionsLoadResult(body))
}
mod tests {
    use super::*;
    use crate::screens_common::test_common::test_shell;

    #[test]
    fn test_load_quick_actions() {
        let mut commands = load_quick_actions();

        // confirm the correct events are called  
        let effects = commands.effects().collect();
        insta::assert_debug_snapshot!(  
            effects, @r#"[
                PersistentStorage(
                    Request(
                       Load("quick_actions",),                
                    ),            
                ),        
            ]"#
        );
        test_shell(effects);
        let events: Vec<Event> = commands.events().collect();
        insta::assert_debug_snapshot!(  
            events, @r#" [
                EvInitialScreen(
                    QuickActionsLoadResult(                    
                         FileData("Just a fake test",),
                      ),
                ),
            ]"#
        );
    }
}

since there is no branching only one test is needed and really all im doing is confirming I called the right construct_response_event function but as I know this function is being called from both the match in app.rs then screen/<name>/mod.rs I can be extremely confident that this function with very little scope for mutability is the side effect of the loadQuickActions event and that it is now 100% tested.

Load quick actions result

Obviously these functions don't stay so small, here's a more average one

pub fn load_quick_actions_result(
    screen_data: &mut InitialScreenData,
    user_messages: &mut UserMessages,
    response: PersistentStorageOutput,
) -> Command<Effect, Event> {
    trace!("load_quick_actions_results called");
    match response {
        PersistentStorageOutput::FileData(data) => {
            debug!("loaded data: {data}");

            let fp = BufReader::new(data.as_bytes());
            let reader = JsonLinesReader::new(fp);
            let items = reader
                .read_all::<QuickAction>()
                .collect::<std::io::Result<Vec<_>>>();

            match items {
                Ok(value) => {
                    model.quick_actions = RemoteResource::Loaded(MultiChoice {
                        options: value,
                        selected: None,
                    });
                    crux_core::render::render()
                }
                Err(error) => {
                    error!("Failed to get previous connections, error: {:?}", error);
                    error!("data was {}", data);
                    user_messages.add_message(
                        format!("Failed to get previous connections, error: {:?}", error),
                        Level::Error,
                    );
                    crux_core::render::render()
                }
            }
        }
        PersistentStorageOutput::SaveOk
        | PersistentStorageOutput::AppendOk
        | PersistentStorageOutput::Error => todo!(),
        PersistentStorageOutput::LoadErrorFileDoesntExist => {
            warn!("Quick actions file doesnt exist!");

            screen_data.quick_actions = RemoteResource::NonExistent;
            crux_core::render::render()
        }
    }
}
Once we add the tests for this the file gets quite huge!
use crate::capabilities::persistent_storage::PersistentStorageOutput;
use crate::model::UserMessages;
use crate::screens::initial::{InitialScreenData, QuickAction};
use crate::screens::input_fields_and_tags::RemoteResource;
use crate::screens_common::model::multi_choice::MultiChoice;
use crate::{Effect, Event};
use crux_core::Command;
use log::{debug, error, trace, warn, Level};
use serde_jsonlines::JsonLinesReader;
use std::io::BufReader;

pub fn load_quick_actions_result(
    model: &mut InitialScreenData,
    user_messages: &mut UserMessages,
    response: PersistentStorageOutput,
) -> Command<Effect, Event> {
    trace!("load_quick_actions_results called");
    match response {
        PersistentStorageOutput::FileData(data) => {
            debug!("loaded data: {data}");

            let fp = BufReader::new(data.as_bytes());
            let reader = JsonLinesReader::new(fp);
            let items = reader
                .read_all::<QuickAction>()
                .collect::<std::io::Result<Vec<_>>>();

            match items {
                Ok(value) => {
                    model.quick_actions = RemoteResource::Loaded(MultiChoice {
                        options: value,
                        selected: None,
                    });
                    crux_core::render::render()
                }
                Err(error) => {
                    error!("Failed to get previous connections, error: {:?}", error);
                    error!("data was {}", data);
                    user_messages.add_message(
                        format!("Failed to get previous connections, error: {:?}", error),
                        Level::Error,
                    );
                    crux_core::render::render()
                }
            }
        }
        PersistentStorageOutput::SaveOk
        | PersistentStorageOutput::AppendOk
        | PersistentStorageOutput::Error => todo!(),
        PersistentStorageOutput::LoadErrorFileDoesntExist => {
            warn!("Quick actions file doesnt exist!");

            model.quick_actions = RemoteResource::NonExistent;
            crux_core::render::render()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::screens::initial::InitialScreenData;
    use crate::screens_common::test_common::test_shell;

    #[test]
    fn test_load_previous_connections_result_empty() {
        let mut screen_data = InitialScreenData {
            ..Default::default()
        };
        let mut user_messages = UserMessages::default();
        let response = PersistentStorageOutput::FileData("".to_owned());

        let mut commands =
            load_quick_actions_result(&mut screen_data, &mut user_messages, response);

        // =========================================================================================
        // check that the view has been updated correctly
        // =========================================================================================
        // todo confirm if I want an empty list or a non exist
        insta::assert_debug_snapshot!(screen_data, @r#"
        InitialScreenData {
            api_token: None,
            base_url: None,
            quick_actions: Loaded(
                MultiChoice {
                    options: [],
                    selected: None,
                },
            ),
            previous_connections: NotLoaded(
                LoadingResource {
                    retry_count: 0,
                    failure_reasons: [],
                    current_state: Initialised,
                },
            ),
            failed_to_contact_influx: false,
        }
        "#);

        insta::assert_debug_snapshot!(user_messages, @r#"
        UserMessages {
            messages: [],
            current_id: 0,
        }
        "#);

        // =========================================================================================
        // confirm the correct events are called
        // =========================================================================================
        let effects = commands.effects().collect();

        insta::assert_debug_snapshot!(
            effects, @r#"
        [
            Render(
                Request(
                    RenderOperation,
                ),
            ),
        ]
        "#);

        test_shell(effects);

        let events: Vec<Event> = commands.events().collect();

        insta::assert_debug_snapshot!(
            events, @"[]");
    }

    #[test]
    fn test_load_previous_connections_result_file_not_exist() {
        let mut screen_data = InitialScreenData {
            ..Default::default()
        };
        let mut user_messages = UserMessages::default();
        let response = PersistentStorageOutput::LoadErrorFileDoesntExist;

        let mut commands =
            load_quick_actions_result(&mut screen_data, &mut user_messages, response);

        // =========================================================================================
        // check that the view has been updated correctly
        // =========================================================================================
        insta::assert_debug_snapshot!(screen_data, @r#"
        InitialScreenData {
            api_token: None,
            base_url: None,
            quick_actions: NonExistent,
            previous_connections: NotLoaded(
                LoadingResource {
                    retry_count: 0,
                    failure_reasons: [],
                    current_state: Initialised,
                },
            ),
            failed_to_contact_influx: false,
        }
        "#);

        insta::assert_debug_snapshot!(user_messages, @r#"
        UserMessages {
            messages: [],
            current_id: 0,
        }
        "#);

        // =========================================================================================
        // confirm the correct events are called
        // =========================================================================================
        let effects = commands.effects().collect();

        insta::assert_debug_snapshot!(
            effects, @r#"
        [
            Render(
                Request(
                    RenderOperation,
                ),
            ),
        ]
        "#);

        test_shell(effects);

        let events: Vec<Event> = commands.events().collect();

        insta::assert_debug_snapshot!(
            events, @"[]");
    }

    #[test]
    fn test_load_previous_connections_result() {
        let mut screen_data = InitialScreenData {
            ..Default::default()
        };
        let mut user_messages = UserMessages::default();
        let response = PersistentStorageOutput::FileData(
            r#"{"name":"test","api_token":"vzO0_9-u3Y0Q-FyO88bIVK8g3beRkvk00hDppgMGiiNVmk1wm0Cmek_QT2hTxcqdUL607FSMtgephwprZPIl7A==","base_url":"http://100.108.38.15:8086/","org":{"name":"test","id":"3aeb5581111a8d5a"},"bucket":"personal","measurement":"test_crux","tags":{"new_tag2":"hey2"},"fields":{"akey":{"Float":{"inner":""}}}}"#
                .to_owned(),
        );

        let mut commands =
            load_quick_actions_result(&mut screen_data, &mut user_messages, response);

        // =========================================================================================
        // check that the view has been updated correctly
        // =========================================================================================
        insta::assert_debug_snapshot!(screen_data, @r#"
        InitialScreenData {
            api_token: None,
            base_url: None,
            quick_actions: Loaded(
                MultiChoice {
                    options: [
                        QuickAction {
                            name: "test",
                            api_token: "vzO0_9-u3Y0Q-FyO88bIVK8g3beRkvk00hDppgMGiiNVmk1wm0Cmek_QT2hTxcqdUL607FSMtgephwprZPIl7A==",
                            base_url: Url {
                                scheme: "http",
                                cannot_be_a_base: false,
                                username: "",
                                password: None,
                                host: Some(
                                    Ipv4(
                                        100.108.38.15,
                                    ),
                                ),
                                port: Some(
                                    8086,
                                ),
                                path: "/",
                                query: None,
                                fragment: None,
                            },
                            org: Org {
                                name: "test",
                                id: "3aeb5581111a8d5a",
                            },
                            bucket: "personal",
                            measurement: "test_crux",
                            tags: {
                                "new_tag2": Some(
                                    "hey2",
                                ),
                            },
                            fields: {
                                "akey": Float(
                                    ValidatedFieldF64 {
                                        inner: "",
                                    },
                                ),
                            },
                        },
                    ],
                    selected: None,
                },
            ),
            previous_connections: NotLoaded(
                LoadingResource {
                    retry_count: 0,
                    failure_reasons: [],
                    current_state: Initialised,
                },
            ),
            failed_to_contact_influx: false,
        }
        "#);

        insta::assert_debug_snapshot!(user_messages, @r#"
        UserMessages {
            messages: [],
            current_id: 0,
        }
        "#);

        // =========================================================================================
        // confirm the correct events are called
        // =========================================================================================
        let effects = commands.effects().collect();

        insta::assert_debug_snapshot!(
            effects, @r#"
        [
            Render(
                Request(
                    RenderOperation,
                ),
            ),
        ]
        "#);

        test_shell(effects);

        let events: Vec<Event> = commands.events().collect();

        insta::assert_debug_snapshot!(
            events, @"[]");
    }
}

Global events / Screen common

Organisation

Global events wise I found it nicer to group them like a screen at the moment I group them in a global folder I am tempted to put them in screens/global. Other than that it's the same structure.

Image of the global events and hightinging the screen_common folder

screens_common is quite simply anything that I could generalise across screens functions are top level and data types are under screens_common/model. Stuff in here will be imported in multiple screen functions.

Global event propagation

A trick I found with some global events was that if the event changes depending on what screen its on we can just make an event in the screens folder

so for the global back button in android Image showing how the back button is originally called in global but defers to screen folders

I implement back depending which screen im on, this will help as well for when I move to iOS because they don't have a global back button, so I will just call these events directly

UiModel (ViewModel)

Last core organisation I did was the UiModel, renamed it UiModel instead of ViewModel for fewer chars, and it made more sense to me.

Image showing the UiModel and how it maps to files

Maybe this could belong in the screens/<name>/mod.rs but those files are big enough and the UiModel crosses the FFi so made sense to keep it separate. Organisation wise very similar setup to screens but now instead of folders I have a file per screen.

90% of this is just from methods :D

Android

Organising jetpack compose

Lastly the Android (shell) side, we can keep a surprisingly similar layout Image showing how android folder structure is layed out

so instead of a match statement per screen we have a when statement working in a very similar way, from there was can call a compose method in a separate file so now every screen has a file under ui/<screen name>

Utilising preview

Then with the lovely @preview annotation we can just build out our UI's while staying all within the one file, I suspect if these files grow much more I may move to a folder structure similar to core. Image showing @preview in jepack compose

Conclusion

In conclusion my folder structure in core (Rust) is

├── app.rs
├── capabilities
│   └── persistent_storage.rs
├── global
│   ├── mod.rs
│   ├── function1.rs
│   └── function2.rs
├── lib.rs
├── model.rs
├── model_ui
│   ├── mod.rs
│   ├── screens
│   │   ├── scren1.rs
│   │   └── screen2.rs
├── screens
│   ├── screen1
│   │   ├── mod.rs
│   │   ├── function1.rs
│   │   └── function2.rs
│   ├── screen2
│   │   ├── mod.rs
│   │   └── function1.rs
├── screens_common
│   ├── mod.rs
│   ├── common_function1.rs
│   ├── common_function2.rs
│   ├── model
│   │   ├── mod.rs
│   │   ├── common_type1.rs
│   │   └── common_type2.rs
└── shared.udl

Pros

Cons