Share
Embed
Under Square's Hood
How Square uses Dagger, Flow and Mortar to build its Register app. Because Fragments hate you and want you to suffer.
rjrjr
September 21, 2014
Transcript
101 slides
-
Square Style • Modularity viaViews • Injected by Dagger •
Navigated by Flow • Presented and scoped by Mortar • Framework -
! ! ! <com.example.player.PlayerView! 7777xmlns:android="http://schemas.android.com/apk/res/android"7 7777android:orientation="vertical"7 7777android:layout_width="match_parent"7android:layout_height="match_parent">7 777777 !!!!<ImageView! 77777777android:layout_width="match_parent"7android:layout_height="0dp"7
77777777android:id="@+id/imageView"7 77777777android:layout_weight="1"/>7 !!!!<Button! 77777777android:layout_width="match_parent"7android:layout_height="wrap_content"7 77777777android:text="Play"7 77777777android:id="@+id/play_button"7 77777777android:layout_gravity="center_horizontal"7 77777777android:layout_weight="0"7 77777777android:layout_marginLeft="32dp"7android:layout_marginRight="32dp"7 77777777android:layout_marginTop="8dp"777android:layout_marginBottom="16dp"/>7 </com.example.player.PlayerView>7 -
! ! public7class7PlayerView7extends7LinearLayout7{7 77private7final7Jukebox7jukebox;7 77private7final7Tweeter7tweeter;7 77 77public7PlayerView(Context7context,7AttributeSet7attrs)7{7 7777super(context,7attrs);7 7777PlayerApp7app7=7(PlayerApp)7context.getApplicationContext();7 7777jukebox7=7app.getJukebox();7
7777tweeter7=7app.getTweeter();7 77}7 -
! ! 7@Override7protected7void7onFinishInflate()7{7 7777super.onFinishInflate();7 7777View7playButton7=7findViewById(R.id.play_button);! ! 7777playButton.setOnClickListener(new7OnClickListener()7{7 777777@Override7public7void7onClick(View7view)7{7 77777777if7(!jukebox.isPlaying())7{7 7777777777jukebox.play();7
7777777777tweeter.tweet("Playing7"7+7jukebox.playing().name());7 77777777}7else7{7 7777777777jukebox.stop();7 77777777}7 777777}7 7777});7 77} -
! ! !7@Override7public7void7onCreate()7{7 7777super.onCreate();7 77 7777httpClient7=7new7OkHttpClient();7 7777audioStreamer7=7new7AudioStreamer(httpClient);7 7777twitterApi7=7new7TwitterApi(httpClient);7 77 7777prefs7=7getSharedPreferences("prefs",7MODE_PRIVATE);7
7777String7twitterUser7=7prefs.getString("twittercuser",7null);7 7777tweeter7=7new7Tweeter(twitterUser,7twitterApi);7 77 7777jukebox7=7new7Jukebox(audioStreamer);7 77} -
Dagger: @Inject Dependencies • Guicecc code generated, Android friendly, fast
fast • @Inject “I want…” • @Module + @Provides “I make…” -
Requesting Dependencies (“I want…”) • @Inject annotation required • Field
injection • Constructor injection -
Field Injection • @Inject on fields which are dependencies •
For the stuff created by Android — Activity,View • Self inflicted -
! ! public7class7PlayerView7extends7LinearLayout7{7 77private7final7Jukebox7jukebox;7 77private7final7Tweeter7tweeter;7 77 77public7PlayerView(Context7context,7AttributeSet7attrs)7{7 7777super(context,7attrs);7 7777PlayerApp7app7=77 77777777(PlayerApp)7context.getApplicationContext();7
7777jukebox7=7app.getJukebox();7 7777tweeter7=7app.getTweeter();7 77}7 }7 -
! ! public7class7PlayerView7extends7LinearLayout7{7 77@Inject7Jukebox7jukebox;7 77@Inject7Tweeter7tweeter;7 77 77public7PlayerView(Context7context,7AttributeSet7attrs)7{7 7777super(context,7attrs);7 7777PlayerApp7app7=77 77777777(PlayerApp)7context.getApplicationContext();7
7777app.inject(this);7 77}! }7 -
Constructor Injection • @Inject on a single constructor • Dagger
creates the object -
Providing Dependencies (“I make…”) • Constructor injected classes are good
to go • Modules are classes that provide dependencies • @Module annotation on the module class • @Provides annotation on its factory methods • @Inject + @Module => ObjectGraph -
! public7class7CloudModule7{7 77 77public7OkHttpClient7provideHttpClient()7{7 7777return7new7OkHttpClient();7 77}7 ! 777 77TwitterApi7provideTwitterApi(OkHttpClient7client)7{7 7777return7new7TwitterApi(client);7
77}7 777 77 77AudioStreamer7provideAudioStreamer(OkHttpClient7client)7{7 7777return7new7AudioStreamer(client);7 77}7 } -
@Module7 public7class7CloudModule7{7 77 77public7OkHttpClient7provideHttpClient()7{7 7777return7new7OkHttpClient();7 77}7 77 777 77TwitterApi7provideTwitterApi(OkHttpClient7client)7{7 7777return7new7TwitterApi(client);7
77}7 777 77 77AudioStreamer7provideAudioStreamer(OkHttpClient7client)7{7 7777return7new7AudioStreamer(client);7 77}7 }7 -
@Module7 public7class7CloudModule7{7 77@Provides7 77public7OkHttpClient7provideHttpClient()7{7 7777return7new7OkHttpClient();7 77}7 77 77@Provides77 77TwitterApi7provideTwitterApi(OkHttpClient7client)7{7 7777return7new7TwitterApi(client);7
77}7 777 77@Provides7 77AudioStreamer7provideAudioStreamer(OkHttpClient7client)7{7 7777return7new7AudioStreamer(client);7 77}7 }7 -
@Module7 public7class7CloudModule7{7 77@Provides7@Singleton7 77public7OkHttpClient7provideHttpClient()7{7 7777return7new7OkHttpClient();7 77}7 77 77@Provides7@Singleton77 77TwitterApi7provideTwitterApi(OkHttpClient7client)7{7 7777return7new7TwitterApi(client);7
77}7 777 77@Provides7@Singleton7 77AudioStreamer7provideAudioStreamer(OkHttpClient7client)7{7 7777return7new7AudioStreamer(client);7 77}7 }7 -
! public7class7PlayerApp7extends7Application7{7 77private7ObjectGraph7objectGraph;7 77 77@Override7public7void7onCreate()7{7 7777super.onCreate();7 77777 7777objectGraph7=7ObjectGraph.create(7 77777777new7CloudModule(),7 77777777new7AppModule(this)7
!!!!);! 77}7 77 77public7<T>7T7inject(T!thing)7{7 7777return7objectGraph.inject(thing);7 77}7 } -
Square Style • Modularity via Views! • Injected by Dagger!
• Navigated by Flow! • Presented and scoped by Mortar ✓ ✓ -
Play Constantinople Sinister Exager… The Booker… Blue Rosebuds Laughing
Song Bach is Dead Elvis & His Boss Laughing Song -
Constantinople Sinister Exager… The Booker… Blue Rosebuds Laughing Song Bach
is Dead Elvis & His Boss Laughing Song Play AlbumListScreen7{7 } AlbumScreen7{7 77id7=723422;77 } TrackScreen7{77 77album7=723422;7 77track7=71;7 } -
! public7class7AlbumListView7extends7LinearLayout7{7 77@Inject7Jukebox7jukebox;7 77@Inject7Flow7flow;7 ! 77@Override7protected7void7onFinishInflate()7{7 7777super.onFinishInflate();7 77 7777listView7=7findById(this,7R.id.album_list);7 7777adapter7=7new7AlbumListAdapter(jukebox);7
7777listView.setAdapter(adapter);7 77 7777listView.setOnItemClickListener(new7OnItemClickListener()7{7 777777@Override7public7void7onItemClick(AdapterView<?>7parent,77 7777777777View7view,7int7position,7long7id)7{7 77777777flow.goTo(new7AlbumScreen(id));7 777777}7 7777});7 77} -
! ! @Layout(R.layout.album_view)7 public7class7AlbumScreen7{7 77public7final7int7id;7 77 77public7AlbumScreen(int7id)7{7 7777this.id7=7id;7 77}7 77
77@Override7 77public7boolean7equals(Object7o)7{7...7}7 77 77@Override7 77public7int7hashCode()7{7...7}! }7 -
Flow: minimalist backstack • Stack of POJO “Screens,” like Intents
or URLs • Store the flow Backstack in the Activity Bundle • Show screens when you’re told to -
! public7class7MainActivity7extends7Activity77 7777implements7Flow.Listener7{7 ! 77@Override7protected7void7onCreate(Bundle7b)7{7 7777super.onCreate(b);7 7777setContentView(R.layout.root_layout);7 ! !!!!Backstack7backstack;7 7777if7(b7==7null)7{7
777777backstack7=7Backstack.single(new7AlbumList());7 7777}7else7{7 777777backstack7=77 7777777777Backstack.from(b.getParcelable(BACKSTACK),7parcer);7 7777}7 77 7777flow7=7new7Flow(backstack,7this);7 7777go(flow.getBackstack(),7REPLACE,7null);7 77}7 -
! public7class7MainActivity7extends7Activity77 7777implements7Flow.Listener7{7 ! 77@Override7protected7void7onCreate(Bundle7b)7{7 7777super.onCreate(b);7 7777setContentView(R.layout.root_layout);7 ! !!!!Backstack7backstack;7 7777if7(b7==7null)7{7
777777backstack7=7Backstack.single(new7AlbumList());7 7777}7else7{7 777777backstack7=77 7777777777Backstack.from(b.getParcelable(BACKSTACK),7parcer);7 7777}7 77 7777flow7=7new7Flow(backstack,7this);7 7777go(flow.getBackstack(),7REPLACE,7null);7 77}7 -
Flow: minimalist backstack • Stack of POJO “Screens,” like Intents
or URLs • Store the flow Backstack in the Activity Bundle • Show screens when you’re told to • Flow#goBack(), Flow#goUp() -
! ! ##/**#Device#back#button#pressed#*/# 77@Override7public7void7onBackPressed()7{7 7777if7(!flow.goBack())7super.onBackPressed();7 77}7 ! 77@Override77 77public7boolean7onOptionItemSelected(MenuItem!item)7{7 7777//7Android7ActionBar7“Up/Home”7button7pressed.7
7777if7(item.getItemId()7==7android.R.id.home)7{7 777777return7flow.goUp();7 7777}7 77777 7777return7super.onOptionsItemSelected(item);7 77}7 -
! ! @Layout(R.layout.track_view)7 public7class7TrackScreen7implements7HasParent7{7 77public7final7int7albumId;7 77public7final7int7trackId;7 77 77public7TrackScreen(int7albumId,7int7trackId)7{7 7777this.albumId7=7albumId;7 7777this.trackId7=7trackId;7
77}7 77 77@Override7public7AlbumScreen7getParent()7{7 7777return7new7AlbumScreen(albumId);7 77}7 ! -
77 77 ##/**#E.g.#FrameScreenSwitcherLayout#*/# 77CanShowView7container;7 77 77@Override7protected7void7onCreate(Bundle7b)7{7 7777super.onCreate(b);7 7777setContentView(R.layout.root_layout);7 7777container7=7(CanShowView)7findViewById(R.id.container);7 77
7777flowBundler.onCreate(b);7 7777AppFlow.loadInitialScreen(this);7 77}7 -
! <com.example.flow.view.TabletLandscapeRoot7 7777xmlns:android="http://schemas.android.com/apk/res/android"7 77 7777android:id="@id/container"7 7777android:orientation="horizontal"7 7777android:layout_width="match_parent"7 7777android:layout_height="match_parent"7 7777>7 77<com.example.flow.view.NavList7
777777android:id=“@+id/nav_list"7 777777android:layout_weight="20"7 777777android:layout_width="0dp"7 777777android:layout_height="match_parent"7 777777/>7 77<com.example.flow.screenswitcher.FrameScreenSwitcherView7 777777android:id="@+id/detail"7 777777android:layout_weight="80"7 777777android:layout_width="0dp"7 777777android:layout_height="match_parent"7 777777/>7 </com.example.flow.view.TabletLandscapeRoot>7 -
! ! ##/**#This#is#how#a#real#Square#app#handles#modals.#*/## 77@Override7public7void7showScreen(Screen7screen,77 7777777final7Flow.Callback7callback)7{7 7777if7(screen7instanceof7ModalScreen)7{7 777777contentFrame.interceptTouches(true);7 777777modalContainer.showScreen(screen,7new7Flow.Callback()7{7 77777777@Override7public7void7onComplete()7{7 7777777777modalContainer.setVisibility(VISIBLE);7
7777777777callback.onComplete();7 77777777}7 777777});7 777777return;7 7777}7else7if7(screen7instanceof7RequiresMerchant)7{7 -
ObjectGraph#plus() • Creates child graph, extends the parent with additional
modules! • Allows creating “scopes” of dependencies -
! ! public7class7MainActivity7extends7Activity7implements7Flow.Listener7{! 77private7FlowBundler7flowBundler7=7...;7 777 [email protected](7 7777addsTo7=7AppModule.class,7 7777injects7=7{7 777777AlbumView.class,7AlbumListView.class,7TrackView.class7 7777})7
77public7class7Module7{7 7777@Provides7Flow7provideFlow()7{7 777777return7flowBundler.getFlow();7 7777}7 77}7 -
777 ! 77private7ObjectGraph7activityGraph;7 ! 77public7<T>7T7inject(T7thing)7{7 7777return7activityGraph.inject(thing);7 77}7 ! 77@Override7protected7void7onCreate(Bundle7b)7{7 7777super.onCreate(b);7
7777PlayerApp7app7=7(PlayerApp)7getApplication();7 7777activityGraph7=7app.getObjectGraph().plus(new7Module());7 7777 7777Backstack7backstack;7 7777if7(b7==7null)7{7 777777backstack7=7Backstack.single(new7AlbumList());7 7777}7else7{7 777777backstack7=77 7777777777Backstack.from(b.getParcelable(BACKSTACK),7parcer);7 -
! ! public7class7AlbumListView7extends7LinearLayout7{7 77@Inject7Jukebox7jukebox;7 77@Inject7Flow7flow;7 77 77public7AlbumListView(Context7context,7AttributeSet7attrs)7{7 7777super(context,7attrs);7 7777((MainActivity)context).inject(this);7 77}7
77 77@Override7protected7void7onFinishInflate()7{7 7777super.onFinishInflate();7 77 7777listView7=7findById(this,7R.id.album_list);7 7777final7adapter7=7new7AlbumListAdapter();7 7777listView.setAdapter(adapter);7 77 7777listView.setOnItemClickListener(new7OnItemClickListener()7{7 -
public7class7TrackView7extends7LinearLayout7{7 77@Inject7TrackViewPresenter7presenter;7 77 77@Override7protected7void7onFinishInflate()7{7 7777super.onFinishInflate();7 7777View7playButton7=7findViewById(R.id.play_button);7 7777playButton.setOnClickListener(new7OnClickListener()7{7 777777@Override7public7void7onClick(View7view)7{7 77777777presenter.onPlayClicked();7 777777}7
7777});7 7777presenter.takeView(this);7 77}7 77 77@Override7protected7void7onDetachedFromWindow()7{7 7777presenter.dropView(this);7 7777super.onDetachedFromWindow();7 77}7 } -
! ! @Singleton7 public7class7TrackViewPresenter7{7 77private7final7Jukebox7jukebox;7 77private7final7Flow7flow;7 77private7final7Tweeter7tweeter;7 77 77@Inject7 77public7TrackViewPresenter(Jukebox7jukebox,7Flow7flow,77
777777Tweeter7tweeter)7{7 7777this.jukebox7=7jukebox;7 7777this.flow7=7flow;7 7777this.tweeter7=7tweeter;7 77}7 -
! ! 7public7void7onPlayClicked()7{7 7777if7(!jukebox.isPlaying())7{7 777777TrackScreen7screen7=7(TrackScreen)77 7777777777flow.getBackstack().current().getScreen();7 ! 777777jukebox.play(screen.albumId,7screen.trackId);7 777777tweeter.tweet("Playing7"7+7jukebox.playing().name());7 7777}7else7{7
777777jukebox.stop();7 7777}7 77}! }7 -
Square Style • Modularity via Views! • Injected by Dagger!
• Navigated by Flow! • Presented and scoped by Mortar ✓ ✓ ✓ -
Constantinople Sinister Exager… The Booker… Blue Rosebuds Laughing Song Bach
is Dead Elvis & His Boss Laughing Song P P -
Mortar: managable, testable • MortarScope enshrines, simplifies ObjectGraph.plus() • Scopes
nest to arbitrary depths • Each screen can be a Blueprint for its own shortlived scope -
! ! @Layout(R.layout.track_view)7@WithModule(TrackScreen.Module.class)7 public7class7TrackScreen7implements7HasParent7{7 77public7final7int7albumId;7 77public7final7int7trackId;7 ! [email protected](! !!!!addsTo7=7MainActivity.Module.class,7 7777injects7=7TrackView.class7
77)7 77class7Module7{7 7777@Provides7TrackScreen7provideScreen()7{77 777777return7TrackScreen.this;!! !!}7 } -
@Singleton77 public7class7TrackViewPresenter7{7 77private7final7Jukebox7jukebox;7 77private7final7Flow7flow;7 77private7final7Tweeter7tweeter;7 77 77@Inject7public7TrackViewPresenter(Jukebox7jukebox,77 777777Flow7flow,7Tweeter7tweeter)7{7 7777this.jukebox7=7jukebox;7 7777this.flow7=7flow;7
7777this.tweeter7=7tweeter;7 77}7 ! 77public7void7onPlayClicked()7{7 777if7(!jukebox.isPlaying())7{7 777777TrackScreen7screen7=7(TrackScreen)77 7777777777flow.getBackstack().current().getScreen();7 ! 777777jukebox.play(screen.albumId,7screen.trackId);7 -
@Singleton7 public7class7TrackViewPresenter7{7 77private7final7Jukebox7jukebox;7 77private7final7TrackScreen7screen;7 77private7final7Tweeter7tweeter;7 77 77@Inject7public7TrackViewPresenter(Jukebox7jukebox,77 777777TrackScreen7screen,7Tweeter7tweeter)7{7 7777this.jukebox7=7jukebox;7 7777this.screen7=7screen;7
7777this.tweeter7=7tweeter;7 77}7 ! 77public7void7onPlayClicked()7{7 777if7(!jukebox.isPlaying())7{7 777777jukebox.play(screen.albumId,7screen.trackId);7 777777tweeter.tweet("Playing7"7+7jukebox.playing().name());7 7777}7else7{7 777777jukebox.stop();7 -
! public7class7TrackViewPresenterTest7{! 77@Mock7Jukebox7jukebox;7 77@Mock7Tweeter7tweeter;7 77TrackScreen7screen7=7new7TrackScreen(100,75);77 ! 77TrackViewPresenter7presenter7=7 777777new7TrackViewPresenter(jukebox,7screen,7tweeter);7 77 77@Before7public7void7setup()7{7
7777initMocks(this);7 77}7 77 77@Test7public7void7playClick_stopsWhenPlaying()7{7 7777when(jukebox.isPlaying()).thenReturn(true);7 7777presenter.onPlayClicked();7 7777verify(jukebox).stop();7 77}7 } -
Mortar: managable, testable • MortarScope enshrines, simplifies ObjectGraph.plus() • Scopes
nest to arbitrary depths • Each screen can be a Blueprint for its own shortlived scope • Presenter class with onLoad(Bundle) and onSave(Bundle) lifecycle -
! ! @Singleton77 public7class7TrackViewPresenter7{7 77private7final7Jukebox7jukebox;7 77private7final7TrackScreen7screen;7 77private7final7Tweeter7tweeter;7 77 77@Inject7public7TrackViewPresenter(Jukebox7jukebox,77 777777Trackscreen7screen,7Tweeter7tweeter)7{7
7777this.jukebox7=7jukebox;7 7777this.screen7=7screen;7 7777this.tweeter7=7tweeter;7 77}7 ! 77public7void7onPlayClicked()7{7 777if7(!jukebox.isPlaying())7{7 777777jukebox.play(screen.albumId,7screen.trackId);7 777777tweeter.tweet("Playing7"7+7jukebox.playing().name());7 -
! ! @Singleton77 public7class7TrackViewPresenter7extends7ViewPresenter<TrackView>7{7 77private7final7Jukebox7jukebox;7 77private7final7TrackScreen7screen;7 77private7final7Tweeter7tweeter;7 77 77@Inject7public7TrackViewPresenter(Jukebox7jukebox,77 777777Trackscreen7screen,7Tweeter7tweeter)7{7
7777this.jukebox7=7jukebox;7 7777this.screen7=7screen;7 7777this.tweeter7=7tweeter;7 77}7 ! 77public7void7onPlayClicked()7{7 777if7(!jukebox.isPlaying())7{7 777777jukebox.play(screen.albumId,7screen.trackId);7 777777tweeter.tweet("Playing7"7+7jukebox.playing().name());7 -
! ! /**#Called#the#view#takes#this#presenter.#*/7 void7onEnterScope()7{! }7 ! /**#Called#each#time#takeView()#receives#a#new#view.#*/7 void7onLoad(Bundle7savedInstanceState)!{! !!getView().setStuffUpIGuess();! }7
! /**#Called#from#Activity#onSaveInstanceState.#*/7 void7onSave(Bundle7outState)!{! }7 ! /**#Called#when#the#scope#is#destroyed.#*/7 void7onExitScope()7{! }7 -
Mortar: managable, testable • MortarScope enshrines, simplifies ObjectGraph.plus() • Scopes
nest to arbitrary depths • Each screen can be a Blueprint for its own shortlived scope • Presenter class with onLoad(Bundle) and onSave(Bundle) lifecycle • Rooted in the Application and Activity -
! public7class7PlayerApp7extends7Application7{7 77private7ObjectGraph7objectGraph;7 77 77@Override7public7void7onCreate()7{7 7777super.onCreate();7 77 7777objectGraph7=7ObjectGraph.create(new7AppModule(this));7 77}7 77
77public7ObjectGraph7getObjectGraph()7{7 7777return7objectGraph;7 77}7 } -
! public7class7PlayerApp7extends7Application7{7 77private7MortarScope7rootScope;7 77 77@Override7public7void7onCreate()7{7 7777super.onCreate();7 77 7777rootScope7=7Mortar.createRootScope(new7AppModule(this));7 77}7 77
77@Override7public7Object7getSystemService(String7name)7{7 7777if7(Mortar.isScopeSystemService(name))7{7 777777return7rootScope;7 7777}7 77 7777return7super.getSystemService(name);7 77}7 }7 -
777 public7class7MainActivity7extends7Activity7implements7Flow.Listener7{7 77private7ObjectGraph7activityGraph;7 ! 77@Override7protected7void7onCreate(Bundle7b)7{7 7777super.onCreate(b);! !!!!setContentView(R.layout.root_layout);! ! 7777PlayerApp7app7=7(PlayerApp)7getApplication();7 7777activityGraph7=7app.getObjectGraph().plus(new7Module());7
7777 7777Backstack7backstack;7 7777if7(b7==7null)7{7 777777backstack7=7Backstack.single(new7AlbumList());7 7777}7else7{7 777777backstack7=77 7777777777Backstack.from(b.getParcelable(BACKSTACK),7parcer);7 7777}7 77 -
777 public7class7MainActivity7extends7Activity7implements7Flow.Listener7{7 77private7ObjectGraph7activityGraph;7 ! 77@Override7protected7void7onCreate(Bundle7b)7{7 7777super.onCreate(b);! !!!!setContentView(R.layout.root_layout);! 77 7777MortarScope7parentScope7=7Mortar.getScope(getApplication());7 7777activityScope7=7Mortar7
77777777.requireActivityScope(parentScope,7new7Blueprint());7 77 7777Backstack7backstack;7 7777if7(b7==7null)7{7 777777backstack7=7Backstack.single(new7AlbumList());7 7777}7else7{7 777777backstack7=77 7777777777Backstack.from(b.getParcelable(BACKSTACK),7parcer);7 7777}7 -
777 ! ! 77public7<T>7T7inject(T!thing)7{7 7777return7activityGraph.inject(thing);7 77}! ! ! ! 77
77public7AlbumListView(Context7context,7AttributeSet7attrs)7{7 7777super(context,7attrs);7 7777((MainActivity)context).inject(this);7 77}7 -
! ! 77@Override7public7Object7getSystemService(String7name)7{7 7777if7(Mortar.isScopeSystemService(name))7{7 777777return7activityScope;7 7777}! !777return7super.getSystemService(name);7 77}7 ! !
77public7AlbumListView(Context7context,7AttributeSet7attrs)7{7 7777super(context,7attrs);7 7777Mortar.inject(context,!this);7 77}7 -
Square Style • Modularity via Views! • Injected by Dagger!
• Navigated by Flow! • Presented and scoped by Mortar ✓ ✓ ✓ ✓