diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 9fc12419d..f0ef8f184 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -111,21 +111,17 @@ In some cases, it can be appropriate to initiate side-effects from the view when ------- ## Simplified Example -When reading through live code trying to understand an architecture, it can be difficult to find canonical examples, and often hard to locate the most important aspects. This is a simplified example using a hypothetical app that should help clarify the above patterns. +When reading through live code trying to understand an architecture, it can be difficult to find canonical examples, and often hard to locate the most important aspects. This is a simplified example of a basic history screen that includes a list of history items and which can be opened, multi-selected, and deleted. -![example app wireframe](./architectureexample/example-app-wireframe.png?raw=true) +The following are links to the example versions of the architectural components listed above. -This app currently has three (wonderful) features. -- Clicking on one of the colored circles will update the toolbar color -- Clicking on 'Rename', typing a new name, and selecting return will update the name of the contact -- Clicking anywhere else on a contact will navigate to a text message fragment - -These link to the architectural code that accomplishes those features: -- [ContactsView](./architectureexample/ContactsView.kt) -- [ContactsStore](./architectureexample/ContactsStore.kt) -- [ContactsState](./architectureexample/ContactsStore.kt) -- [ContactsReducer](./architectureexample/ContactsStore.kt) -- [ContactsFragment](./architectureexample/ContactsFragment.kt) +- [HistoryFragment](./architectureexample/HistoryFragmentExample.kt) +- [HistoryStore](./architectureexample/HistoryStoreExample.kt) +- [HistoryState](./architectureexample/HistoryStoreExample.kt) +- [HistoryReducer](./architectureexample/HistoryStoreExample.kt) +- [HistoryNavigationMiddleware](./architectureexample/HistoryNavigationMiddlewareExample.kt) +- [HistoryStorageMiddleware](./architectureexample/HistoryStorageMiddlewareExample.kt) +- [HistoryTelemetryMiddleware](./architectureexample/HistoryTelemetryMiddlewareExample.kt) ------- diff --git a/docs/architectureexample/ContactsController.kt b/docs/architectureexample/ContactsController.kt deleted file mode 100644 index e9274ce73..000000000 --- a/docs/architectureexample/ContactsController.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -// This is example code for the 'Simplified Example' section of -// /docs/architecture-overview.md -class ContactsController( - private val store: ContactsStore, - private val navController: NavController, -) { - - fun contactRenamed(contactId: Int, newName: String) { - store.dispatch(ContactsAction.ContactRenamed(contactId = contactId, newName = newName)) - } - - fun chatSelected(contactId: Int) { - // This is how we pass arguments between fragments using Google's navigation library. - // See https://developer.android.com/guide/navigation/navigation-getting-started - val directions = ContactsFragment.actionContactsFragmentToChatFragment( - contactId = contactId, - ) - navController.nav(R.id.contactFragment, directions) - } -} diff --git a/docs/architectureexample/ContactsFragment.kt b/docs/architectureexample/ContactsFragment.kt deleted file mode 100644 index d72753233..000000000 --- a/docs/architectureexample/ContactsFragment.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - - -// This is example code for the 'Simplified Example' section of -// /docs/architecture-overview.md -class ContactsFragment : Fragment() { - - lateinit var contactsStore: ContactsStore - lateinit var contactsView: ContactsView - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val view = inflater.inflate(R.layout.fragment_contacts, container, false) - - // Create the various components and hook them up to each other - val initialState = ContactsState( - contacts = emptyList(), - theme = Theme.ORANGE - ) - - contactsStore = ContactsStore(initialState = initialState) - - val contactsController = ContactsController( - store = store, - navController = findNavController() - ) - - val themeController = ThemeController( - store = store - ) - - val interactor = ContactsInteractor( - contactsController = contactsController, - themeController = themeController - ) - - contactsView = ContactsView(view.contains_container, interactor) - } - - override onViewCreated(view: View, savedInstanceState: Bundle?) { - // Whenever State is updated, pass it to the View - consumeFrom(contactsStore) { state -> - contactsView.update(state) - } - } -} diff --git a/docs/architectureexample/ContactsInteractor.kt b/docs/architectureexample/ContactsInteractor.kt deleted file mode 100644 index 02bc65c94..000000000 --- a/docs/architectureexample/ContactsInteractor.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -// This is example code for the 'Simplified Example' section of -// /docs/architecture-overview.md -class ContactsInteractor( - private val contactsController: ContactsController, - private val themeController: ThemeController, -) { - - fun onThemeSelected(theme: Theme) { - themeController.themeSelected(theme) - } - - fun onContactRenamed(contactId: Int, newName: String) { - contactsController.contactRenamed(contactId, newName) - } - - fun onChatSelected(contactId: Int) { - contactsController.chatSelected(contactId) - } -} diff --git a/docs/architectureexample/ContactsStore.kt b/docs/architectureexample/ContactsStore.kt deleted file mode 100644 index 7231d7b05..000000000 --- a/docs/architectureexample/ContactsStore.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -// This is example code for the 'Simplified Example' section of -// /docs/architecture-overview.md -class ContactsStore( - private val initialState: ContactsState, -) : Store>(initialState, ::reducer) - -sealed class ContactsAction { - data class ContactRenamed(val contactId: Int, val newName: String) : ContactsAction - data class ThemeChanged(val newTheme: Theme) : ContactsAction -} - -data class ContactsState( - val contacts: List, - val theme: Theme, -) - -data class Contact( - val name: String, - val id: Int, - val imageUrl: Uri, -) - -enum class Theme { - ORANGE, DARK -} - -fun reducer(oldState: ContactsState, action: ContactsAction): ContactsState = when (action) { - is ContactsAction.ThemeChanged -> oldState.copy(theme = action.newTheme) - is ContactsAction.ContactRenamed -> { - val newContacts = oldState.contacts.map { contact -> - // If this is the contact we want to change... - if (contact.id == action.contactId) { - // Update its name, but keep other values the same - contact.copy(name = newName) - } else { - // Otherwise return the original contact - return@map contact - } - } - - return oldState.copy(contacts = newContacts) - } -} diff --git a/docs/architectureexample/ContactsView.kt b/docs/architectureexample/ContactsView.kt deleted file mode 100644 index a6a7dc872..000000000 --- a/docs/architectureexample/ContactsView.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -// This is example code for the 'Simplified Example' section of -// /docs/architecture-overview.md -class ContactsView( - private val container: ViewGroup, - private val interactor: ContactsInteractor, -) { - - val view: View = LayoutInflater.from(container.context) - .inflate(R.layout.contact_list, container, true) - - private val contactAdapter: ContactAdapter - - init { - // Setup view constraints and anything else that will not change as data updates - view.select_theme_orange.setOnClickListener { - interactor.onThemeSelected(Theme.ORANGE) - } - view.select_theme_dark.setOnClickListner { - interactor.onThemeSelected(Theme.DARK) - } - // The RecyclerView.Adapter is passed the interactor, and will call it from its own listeners - contactAdapter = ContactAdapter(view.contactRoot, interactor) - view.contact_recycler.apply { - adapter = contactAdapter - } - } - - fun update(state: ContactsState) { - view.toolbar.setColor(ContextCompat.getColor(this, R.color.state.toolbarColor)) - contactAdapter.update(state) - } -} diff --git a/docs/architectureexample/HistoryFragmentExample.kt b/docs/architectureexample/HistoryFragmentExample.kt new file mode 100644 index 000000000..f3fa6e3ee --- /dev/null +++ b/docs/architectureexample/HistoryFragmentExample.kt @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This is example code for the 'Simplified Example' section of +// /docs/architecture-overview.md +class HistoryFragment : Fragment() { + + private val store by lazy { + StoreProvider.get(this) { + HistoryStore( + initialState = HistoryState.initial, + middleware = listOf( + HistoryNavigationMiddleware(findNavController()) + HistoryStorageMiddleware(HistoryStorage()), + HistoryTelemetryMiddleware(), + ) + ) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return ComposeView(requireContext()).apply { + setContent { + HistoryScreen(store) + } + } + } +} + +@Composable +private fun HistoryScreen(store: HistoryStore) { + val state = store.observeAsState(initialValue = HistoryState.initial) { state -> state } + val listState = rememberLazyListState() + LazyColumn(listState) { + if (state.selectedItems.isNotEmpty()) { + HistoryMultiSelectHeader( + onDeleteSelectedClick = { + store.dispatch(HistoryAction.DeleteItems(state.selectedItems)) + } + ) + } else { + HistoryHeader( + onDeleteAllClick = { store.dispatch(HistoryAction.DeleteItems(state.items)) } + ) + } + items(items = state.displayItems, key = { item -> item.id } ) { item -> + val isSelected = state.selectedItems.find { selectedItem -> + selectdItem == item + } + HistoryItem( + item = item, + isSelected = isSelected, + onClick = { store.dispatch(HistoryAction.OpenItem(item)) }, + onLongClick = { store.dispatch(HistoryAction.ToggleItemSelection(item)) }, + onDeleteClick = { store.dispatch(HistoryAction.DeleteItems(listOf(item))) }, + ) + } + } +} diff --git a/docs/architectureexample/HistoryNavigationMiddlewareExample.kt b/docs/architectureexample/HistoryNavigationMiddlewareExample.kt new file mode 100644 index 000000000..49f3d42f2 --- /dev/null +++ b/docs/architectureexample/HistoryNavigationMiddlewareExample.kt @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This is example code for the 'Simplified Example' section of +// /docs/architecture-overview.md +class HistoryNavigationMiddleware( + private val navController: NavController, +) : Middleware { + override fun invoke( + context: MiddlewareContext, + next: (HistoryAction) -> Unit, + action: HistoryAction, + ) { + // This middleware won't need to manipulate the action, so the action can be passed through + // the middleware chain before the side-effects are initiated + next(action) + when(action) { + is HistoryAction.OpenItem -> { + navController.openToBrowserAndLoad( + searchTermOrURL = item.url, + newTab = true, + from = BrowserDirection.FromHistory, + ) + } + else -> Unit + } + } +} diff --git a/docs/architectureexample/HistoryStorageMiddlewareExample.kt b/docs/architectureexample/HistoryStorageMiddlewareExample.kt new file mode 100644 index 000000000..674adca85 --- /dev/null +++ b/docs/architectureexample/HistoryStorageMiddlewareExample.kt @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This is example code for the 'Simplified Example' section of +// /docs/architecture-overview.md +class HistoryStorageMiddleware( + private val storage: HistoryStorage + private val scope: CoroutineScope, +) : Middleware { + override fun invoke( + context: MiddlewareContext, + next: (HistoryAction) -> Unit, + action: HistoryAction, + ) { + // This middleware won't need to manipulate the action, so the action can be passed through + // the middleware chain before the side-effects are initiated + next(action) + when(action) { + is HistoryAction.Init -> { + scope.launch { + val history = storage.load() + context.store.dispatch(HistoryAction.ItemsChanged(history)) + } + } + is HistoryAction.DeleteItems -> { + scope.launch { + val currentItems = context.state.items + if (storage.delete(action.items) is HistoryStorage.Success) { + context.store.dispatch( + HistoryAction.DeleteFinished() + ) + } + } + } + else -> Unit + } + } +} diff --git a/docs/architectureexample/HistoryStoreExample.kt b/docs/architectureexample/HistoryStoreExample.kt new file mode 100644 index 000000000..c53d8d298 --- /dev/null +++ b/docs/architectureexample/HistoryStoreExample.kt @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This is example code for the 'Simplified Example' section of +// /docs/architecture-overview.md +class HistoryStore( + private val initialState: HistoryState, + private val middleware: List> +) : Store>(initialState, middleware, ::reducer) { + init { + // This will ensure that middlewares can take any actions they need to during initialization + dispatch(HistoryAction.Init) + } +} + +sealed class HistoryAction { + object Init : HistoryAction() + data class ItemsChanged(val items: List) : HistoryAction() + data class DeleteItems(val items: List) : HistoryAction() + data class DeleteFinished() : HistoryAction() + data class ToggleItemSelection(val item: History) : HistoryAction() + data class OpenItem(val item: History) : HistoryAction() +} + +data class HistoryState( + val items: List, + val selectedItems: List, + val itemsBeingDeleted: List, + companion object { + val initial = HistoryState( + items = listOf(), + selectedItems = listOf(), + itemsBeingDeleted = listOf(), + ) + } +) { + val displayItems = items.filter { item -> + item !in itemsBeingDeleted + } +} + +fun reducer(oldState: HistoryState, action: HistoryAction): HistoryState = when (action) { + is HistoryAction.ItemsChanged -> oldState.copy(items = action.items) + is HistoryAction.DeleteItems -> oldState.copy(itemsBeingDeleted = action.items) + is HistoryAction.DeleteFinished -> oldState.copy( + items = oldState.items - oldState.itemsBeingDeleted, + itemsBeingDeleted = listOf(), + ) + is HistoryAction.ToggleItemSelection -> { + if (oldState.selectedItems.contains(action.item)) { + oldState.copy(selectedItems = oldState.selectedItems - action.item) + } else { + oldState.copy(selectedItems = oldState.selectedItems + action.item) + } + } + else -> Unit +} + +data class History( + val id: Int, + val title: String, + val url: Uri, +) diff --git a/docs/architectureexample/HistoryTelemetryMiddlewareExample.kt b/docs/architectureexample/HistoryTelemetryMiddlewareExample.kt new file mode 100644 index 000000000..9bda08cee --- /dev/null +++ b/docs/architectureexample/HistoryTelemetryMiddlewareExample.kt @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This is example code for the 'Simplified Example' section of +// /docs/architecture-overview.md +class HistoryTelemetryMiddleware : Middleware { + override fun invoke( + context: MiddlewareContext, + next: (HistoryAction) -> Unit, + action: HistoryAction, + ) { + // This middleware won't need to manipulate the action, so the action can be passed through + // the middleware chain before the side-effects are initiated + next(action) + when(action) { + is HistoryAction.DeleteItems -> History.itemsDeleted.record() + is HistoryAction.OpenItem -> History.itemOpened.record() + else -> Unit + } + } +} diff --git a/docs/architectureexample/ThemeController.kt b/docs/architectureexample/ThemeController.kt deleted file mode 100644 index d4677e8e1..000000000 --- a/docs/architectureexample/ThemeController.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - - -// This is example code for the 'Simplified Example' section of -// /docs/architecture-overview.md -class ThemeController( - private val ContactsStore -) { - fun themeSelected(newTheme: Theme) { - store.dispatch(ContactsAction.ThemeChanged(newTheme = newTheme)) - } -} diff --git a/docs/architectureexample/example-app-wireframe.png b/docs/architectureexample/example-app-wireframe.png deleted file mode 100644 index b1566d9d4..000000000 Binary files a/docs/architectureexample/example-app-wireframe.png and /dev/null differ