diff --git a/demo/build.gradle b/demo/build.gradle index 23fbeda3..7e7983a0 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -1,5 +1,17 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-serialization:1.8.0" + } +} android { compileSdkVersion 33 @@ -13,6 +25,10 @@ android { vectorDrawables.useSupportLibrary = true } + buildFeatures { + viewBinding = true + } + buildTypes { release { minifyEnabled false @@ -49,11 +65,13 @@ android { dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation 'com.google.android.material:material:1.8.0' + implementation 'com.google.android.material:material:1.9.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.recyclerview:recyclerview:1.3.0' + implementation 'androidx.recyclerview:recyclerview:1.3.1' implementation 'androidx.browser:browser:1.5.0' + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0' implementation 'com.github.bumptech.glide:glide:4.15.1' + implementation 'dev.hotwire:strada:1.0.0-beta2' implementation project(':turbo') } diff --git a/demo/src/main/assets/json/configuration.json b/demo/src/main/assets/json/configuration.json index abc40df1..6436ba90 100644 --- a/demo/src/main/assets/json/configuration.json +++ b/demo/src/main/assets/json/configuration.json @@ -26,7 +26,8 @@ }, { "patterns": [ - "/signin$" + "/signin$", + "/strada-form$" ], "properties": { "context": "modal", diff --git a/demo/src/main/kotlin/dev/hotwire/turbo/demo/base/NavDestination.kt b/demo/src/main/kotlin/dev/hotwire/turbo/demo/base/NavDestination.kt index 4f8a64ef..857e2a37 100644 --- a/demo/src/main/kotlin/dev/hotwire/turbo/demo/base/NavDestination.kt +++ b/demo/src/main/kotlin/dev/hotwire/turbo/demo/base/NavDestination.kt @@ -7,6 +7,7 @@ import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_ON import androidx.navigation.NavOptions import androidx.navigation.navOptions +import dev.hotwire.strada.BridgeDestination import dev.hotwire.turbo.config.TurboPathConfigurationProperties import dev.hotwire.turbo.config.context import dev.hotwire.turbo.demo.R @@ -14,7 +15,7 @@ import dev.hotwire.turbo.demo.util.BASE_URL import dev.hotwire.turbo.nav.TurboNavDestination import dev.hotwire.turbo.nav.TurboNavPresentationContext.MODAL -interface NavDestination : TurboNavDestination { +interface NavDestination : TurboNavDestination, BridgeDestination { val menuProgress: MenuItem? get() = toolbarForNavigation()?.menu?.findItem(R.id.menu_progress) @@ -38,6 +39,10 @@ interface NavDestination : TurboNavDestination { } } + override fun bridgeWebViewIsReady(): Boolean { + return session.isReady + } + private fun isNavigable(location: String): Boolean { return location.startsWith(BASE_URL) } diff --git a/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/numbers/NumbersAdapter.kt b/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/numbers/NumbersAdapter.kt index 85f7d687..0ba21a54 100644 --- a/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/numbers/NumbersAdapter.kt +++ b/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/numbers/NumbersAdapter.kt @@ -1,5 +1,6 @@ package dev.hotwire.turbo.demo.features.numbers +import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -11,6 +12,7 @@ class NumbersAdapter(val callback: NumbersFragmentCallback) : RecyclerView.Adapt private val type = R.layout.adapter_numbers_row private var items = emptyList() + @SuppressLint("NotifyDataSetChanged") set(value) { field = value notifyDataSetChanged() diff --git a/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/web/WebFragment.kt b/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/web/WebFragment.kt index 1637640d..a3aff4e7 100644 --- a/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/web/WebFragment.kt +++ b/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/web/WebFragment.kt @@ -2,19 +2,52 @@ package dev.hotwire.turbo.demo.features.web import android.os.Bundle import android.view.View +import dev.hotwire.strada.BridgeDelegate import dev.hotwire.turbo.demo.R import dev.hotwire.turbo.demo.base.NavDestination +import dev.hotwire.turbo.demo.strada.bridgeComponentFactories import dev.hotwire.turbo.demo.util.SIGN_IN_URL import dev.hotwire.turbo.fragments.TurboWebFragment import dev.hotwire.turbo.nav.TurboNavGraphDestination +import dev.hotwire.turbo.views.TurboWebView import dev.hotwire.turbo.visit.TurboVisitAction.REPLACE import dev.hotwire.turbo.visit.TurboVisitOptions @TurboNavGraphDestination(uri = "turbo://fragment/web") open class WebFragment : TurboWebFragment(), NavDestination { + private val bridgeDelegate by lazy { + BridgeDelegate( + location = location, + destination = this, + componentFactories = bridgeComponentFactories + ) + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupMenu() + viewLifecycleOwner.lifecycle.addObserver(bridgeDelegate) + } + + override fun onDestroyView() { + super.onDestroyView() + viewLifecycleOwner.lifecycle.removeObserver(bridgeDelegate) + } + + override fun onColdBootPageStarted(location: String) { + bridgeDelegate.onColdBootPageStarted() + } + + override fun onColdBootPageCompleted(location: String) { + bridgeDelegate.onColdBootPageCompleted() + } + + override fun onWebViewAttached(webView: TurboWebView) { + bridgeDelegate.onWebViewAttached(webView) + } + + override fun onWebViewDetached(webView: TurboWebView) { + bridgeDelegate.onWebViewDetached() } override fun onFormSubmissionStarted(location: String) { diff --git a/demo/src/main/kotlin/dev/hotwire/turbo/demo/main/MainActivity.kt b/demo/src/main/kotlin/dev/hotwire/turbo/demo/main/MainActivity.kt index 122ab44a..b8c4ce84 100644 --- a/demo/src/main/kotlin/dev/hotwire/turbo/demo/main/MainActivity.kt +++ b/demo/src/main/kotlin/dev/hotwire/turbo/demo/main/MainActivity.kt @@ -3,6 +3,8 @@ package dev.hotwire.turbo.demo.main import android.os.Bundle import android.webkit.WebView import androidx.appcompat.app.AppCompatActivity +import dev.hotwire.strada.KotlinXJsonConverter +import dev.hotwire.strada.Strada import dev.hotwire.turbo.BuildConfig import dev.hotwire.turbo.activities.TurboActivity import dev.hotwire.turbo.config.Turbo @@ -21,8 +23,11 @@ class MainActivity : AppCompatActivity(), TurboActivity { } private fun configApp() { + Strada.config.jsonConverter = KotlinXJsonConverter() + if (BuildConfig.DEBUG) { Turbo.config.debugLoggingEnabled = true + Strada.config.debugLoggingEnabled = true WebView.setWebContentsDebuggingEnabled(true) } } diff --git a/demo/src/main/kotlin/dev/hotwire/turbo/demo/main/MainSessionNavHostFragment.kt b/demo/src/main/kotlin/dev/hotwire/turbo/demo/main/MainSessionNavHostFragment.kt index 50d02ef8..0e470dc0 100644 --- a/demo/src/main/kotlin/dev/hotwire/turbo/demo/main/MainSessionNavHostFragment.kt +++ b/demo/src/main/kotlin/dev/hotwire/turbo/demo/main/MainSessionNavHostFragment.kt @@ -2,6 +2,7 @@ package dev.hotwire.turbo.demo.main import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment +import dev.hotwire.strada.Bridge import dev.hotwire.turbo.config.TurboPathConfiguration import dev.hotwire.turbo.demo.features.imageviewer.ImageViewerFragment import dev.hotwire.turbo.demo.features.numbers.NumberBottomSheetFragment @@ -43,7 +44,12 @@ class MainSessionNavHostFragment : TurboSessionNavHostFragment() { override fun onSessionCreated() { super.onSessionCreated() + + // Configure WebView session.webView.settings.userAgentString = session.webView.customUserAgent session.webView.initDayNightTheme() + + // Initialize Strada bridge with new WebView instance + Bridge.initialize(session.webView) } } diff --git a/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/BridgeComponentFactories.kt b/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/BridgeComponentFactories.kt new file mode 100644 index 00000000..b65499fe --- /dev/null +++ b/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/BridgeComponentFactories.kt @@ -0,0 +1,9 @@ +package dev.hotwire.turbo.demo.strada + +import dev.hotwire.strada.BridgeComponentFactory + +val bridgeComponentFactories = listOf( + BridgeComponentFactory("form", ::FormComponent), + BridgeComponentFactory("menu", ::MenuComponent), + BridgeComponentFactory("overflow-menu", ::OverflowMenuComponent) +) diff --git a/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/FormComponent.kt b/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/FormComponent.kt new file mode 100644 index 00000000..8ba26118 --- /dev/null +++ b/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/FormComponent.kt @@ -0,0 +1,92 @@ +package dev.hotwire.turbo.demo.strada + +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.Fragment +import dev.hotwire.strada.BridgeComponent +import dev.hotwire.strada.BridgeDelegate +import dev.hotwire.strada.Message +import dev.hotwire.turbo.demo.R +import dev.hotwire.turbo.demo.base.NavDestination +import dev.hotwire.turbo.demo.databinding.FormComponentSubmitBinding +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Bridge component to display a submit button in the native toolbar, + * which will submit the form on the page when tapped. + */ +class FormComponent( + name: String, + private val delegate: BridgeDelegate +) : BridgeComponent(name, delegate) { + + private val submitButtonItemId = 37 + private var submitMenuItem: MenuItem? = null + private val fragment: Fragment + get() = delegate.destination.fragment + private val toolbar: Toolbar? + get() = fragment.view?.findViewById(R.id.toolbar) + + override fun onReceive(message: Message) { + when (message.event) { + "connect" -> handleConnectEvent(message) + "submitEnabled" -> handleSubmitEnabled() + "submitDisabled" -> handleSubmitDisabled() + else -> Log.w("TurboDemo", "Unknown event for message: $message") + } + } + + private fun handleConnectEvent(message: Message) { + val data = message.data() ?: return + showToolbarButton(data) + } + + private fun handleSubmitEnabled() { + toggleSubmitButton(true) + } + + private fun handleSubmitDisabled() { + toggleSubmitButton(false) + } + + private fun showToolbarButton(data: MessageData) { + val menu = toolbar?.menu ?: return + val inflater = LayoutInflater.from(fragment.requireContext()) + val binding = FormComponentSubmitBinding.inflate(inflater) + val order = 999 // Show as the right-most button + + binding.formSubmit.apply { + text = data.title + setOnClickListener { + performSubmit() + } + } + + menu.removeItem(submitButtonItemId) + submitMenuItem = menu.add(Menu.NONE, submitButtonItemId, order, data.title).apply { + actionView = binding.root + setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + } + } + + private fun toggleSubmitButton(enable: Boolean) { + val layout = submitMenuItem?.actionView ?: return + + FormComponentSubmitBinding.bind(layout).apply { + formSubmit.isEnabled = enable + } + } + + private fun performSubmit(): Boolean { + return replyTo("connect") + } + + @Serializable + data class MessageData( + @SerialName("submitTitle") val title: String + ) +} diff --git a/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/MenuComponent.kt b/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/MenuComponent.kt new file mode 100644 index 00000000..5749e64b --- /dev/null +++ b/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/MenuComponent.kt @@ -0,0 +1,82 @@ +package dev.hotwire.turbo.demo.strada + +import android.util.Log +import android.view.LayoutInflater +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.bottomsheet.BottomSheetDialog +import dev.hotwire.strada.BridgeComponent +import dev.hotwire.strada.BridgeDelegate +import dev.hotwire.strada.Message +import dev.hotwire.turbo.demo.base.NavDestination +import dev.hotwire.turbo.demo.databinding.MenuComponentBottomSheetBinding +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Bridge component to display a native bottom sheet menu, which will + * send the selected index of the tapped menu item back to the web. + */ +class MenuComponent( + name: String, + private val delegate: BridgeDelegate +) : BridgeComponent(name, delegate) { + + private val fragment: Fragment + get() = delegate.destination.fragment + + override fun onReceive(message: Message) { + when (message.event) { + "display" -> handleDisplayEvent(message) + else -> Log.w("TurboDemo", "Unknown event for message: $message") + } + } + + private fun handleDisplayEvent(message: Message) { + val data = message.data() ?: return + showBottomSheet(data.title, data.items) + } + + private fun showBottomSheet(title: String, items: List) { + val view = fragment.view?.rootView ?: return + val inflater = LayoutInflater.from(view.context) + val bottomSheet = BottomSheetDialog(view.context) + val binding = MenuComponentBottomSheetBinding.inflate(inflater) + + binding.toolbar.title = title + binding.recyclerView.layoutManager = LinearLayoutManager(view.context) + binding.recyclerView.adapter = MenuComponentAdapter().apply { + setData(items) + setListener { + bottomSheet.dismiss() + onItemSelected(it) + } + } + + bottomSheet.apply { + setContentView(binding.root) + show() + } + } + + private fun onItemSelected(item: Item) { + replyTo("display", SelectionMessageData(item.index)) + } + + @Serializable + data class MessageData( + @SerialName("title") val title: String, + @SerialName("items") val items: List + ) + + @Serializable + data class Item( + @SerialName("title") val title: String, + @SerialName("index") val index: Int + ) + + @Serializable + data class SelectionMessageData( + @SerialName("selectedIndex") val selectedIndex: Int + ) +} diff --git a/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/MenuComponentAdapter.kt b/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/MenuComponentAdapter.kt new file mode 100644 index 00000000..cc33a533 --- /dev/null +++ b/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/MenuComponentAdapter.kt @@ -0,0 +1,57 @@ +package dev.hotwire.turbo.demo.strada + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.textview.MaterialTextView +import dev.hotwire.turbo.demo.R + +class MenuComponentAdapter : RecyclerView.Adapter() { + private val type = R.layout.menu_component_adapter_row + private var action: ((MenuComponent.Item) -> Unit)? = null + + private var items = emptyList() + @SuppressLint("NotifyDataSetChanged") + set(value) { + field = value + notifyDataSetChanged() + } + + fun setData(items: List) { + this.items = items + } + + fun setListener(action: (item: MenuComponent.Item) -> Unit) { + this.action = action + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount(): Int { + return items.count() + } + + override fun getItemViewType(position: Int): Int { + return type + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) + return ViewHolder(view) + } + + inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private val textView: MaterialTextView = view.findViewById(R.id.title) + + fun bind(item: MenuComponent.Item) { + textView.text = item.title + itemView.setOnClickListener { + action?.invoke(item) + } + } + } +} diff --git a/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/OverflowMenuComponent.kt b/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/OverflowMenuComponent.kt new file mode 100644 index 00000000..13649c14 --- /dev/null +++ b/demo/src/main/kotlin/dev/hotwire/turbo/demo/strada/OverflowMenuComponent.kt @@ -0,0 +1,67 @@ +package dev.hotwire.turbo.demo.strada + +import android.util.Log +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.Fragment +import dev.hotwire.strada.BridgeComponent +import dev.hotwire.strada.BridgeDelegate +import dev.hotwire.strada.Message +import dev.hotwire.turbo.demo.R +import dev.hotwire.turbo.demo.base.NavDestination +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Bridge component to display a native 3-dot menu in the toolbar, which + * will will notify the web when it has been tapped. + */ +class OverflowMenuComponent( + name: String, + private val delegate: BridgeDelegate +) : BridgeComponent(name, delegate) { + + private val fragment: Fragment + get() = delegate.destination.fragment + private val toolbar: Toolbar? + get() = fragment.view?.findViewById(R.id.toolbar) + + override fun onReceive(message: Message) { + when (message.event) { + "connect" -> handleConnectEvent(message) + else -> Log.w("TurboDemo", "Unknown event for message: $message") + } + } + + private fun handleConnectEvent(message: Message) { + val data = message.data() ?: return + showOverflowMenuItem(data) + } + + private fun showOverflowMenuItem(data: MessageData) { + val toolbar = toolbar ?: return + + toolbar.menu.findItem(R.id.overflow)?.apply { + isVisible = true + title = data.label + } + + toolbar.setOnMenuItemClickListener { + when (it.itemId) { + R.id.overflow -> { + performClick() + true + } + else -> false + } + } + } + + private fun performClick() { + replyTo("connect") + } + + @Serializable + data class MessageData( + @SerialName("label") val label: String + ) +} diff --git a/demo/src/main/kotlin/dev/hotwire/turbo/demo/util/Extensions.kt b/demo/src/main/kotlin/dev/hotwire/turbo/demo/util/Extensions.kt index a0da63e4..7cfa592e 100644 --- a/demo/src/main/kotlin/dev/hotwire/turbo/demo/util/Extensions.kt +++ b/demo/src/main/kotlin/dev/hotwire/turbo/demo/util/Extensions.kt @@ -8,9 +8,11 @@ import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewFeature +import dev.hotwire.strada.Strada import dev.hotwire.turbo.config.Turbo import dev.hotwire.turbo.config.TurboPathConfigurationProperties import dev.hotwire.turbo.demo.R +import dev.hotwire.turbo.demo.strada.bridgeComponentFactories val TurboPathConfigurationProperties.description: String? get() = get("description") @@ -38,7 +40,8 @@ fun WebView.initDayNightTheme() { val WebView.customUserAgent: String get() { val turboSubstring = Turbo.userAgentSubstring() - return "$turboSubstring; ${settings.userAgentString}" + val stradaSubstring = Strada.userAgentSubstring(bridgeComponentFactories) + return "$turboSubstring; $stradaSubstring; ${settings.userAgentString}" } private fun isNightModeEnabled(context: Context): Boolean { diff --git a/demo/src/main/res/drawable/ic_overflow.xml b/demo/src/main/res/drawable/ic_overflow.xml new file mode 100644 index 00000000..568cbb4d --- /dev/null +++ b/demo/src/main/res/drawable/ic_overflow.xml @@ -0,0 +1,9 @@ + + + diff --git a/demo/src/main/res/layout/form_component_submit.xml b/demo/src/main/res/layout/form_component_submit.xml new file mode 100644 index 00000000..c8cacec4 --- /dev/null +++ b/demo/src/main/res/layout/form_component_submit.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/demo/src/main/res/layout/menu_component_adapter_row.xml b/demo/src/main/res/layout/menu_component_adapter_row.xml new file mode 100644 index 00000000..64f8aa6b --- /dev/null +++ b/demo/src/main/res/layout/menu_component_adapter_row.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/demo/src/main/res/layout/menu_component_bottom_sheet.xml b/demo/src/main/res/layout/menu_component_bottom_sheet.xml new file mode 100644 index 00000000..e52af604 --- /dev/null +++ b/demo/src/main/res/layout/menu_component_bottom_sheet.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + diff --git a/demo/src/main/res/menu/web.xml b/demo/src/main/res/menu/web.xml index a96f5cda..0452d0a5 100644 --- a/demo/src/main/res/menu/web.xml +++ b/demo/src/main/res/menu/web.xml @@ -11,4 +11,13 @@ app:showAsAction="always" tools:ignore="AlwaysShowAction" /> + +