diff --git a/CHANGELOG.md b/CHANGELOG.md index e1224e4aa..22358ca29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added +- New `IterableInboxToolbarView` — an opt-in, reusable toolbar component for the inbox UI. Configurable via the new Kotlin sealed interface `InboxToolbarOption`: + - `None` (default) — no toolbar; behavior is unchanged from prior SDK versions. + - `Default` — title-only toolbar above the inbox list. + - `WithBackButton` — title plus a back navigation icon. The default back action calls `OnBackPressedDispatcher`; override it by having the host Activity or parent Fragment implement `IterableInboxToolbarBackListener`. + - `Custom(layoutRes)` — inflates the integrator's own toolbar layout. Views tagged with the reserved ids `@id/iterable_reserved_inbox_toolbar_action` and `@id/iterable_reserved_inbox_toolbar_title` are auto-wired to the SDK's back handler and title binding respectively. Both ids are optional. + - Configure programmatically via `IterableInboxFragment.newInstance(...)` (new 2-arg and 6-arg overloads) or via `IterableInboxActivity` intent extras (`TOOLBAR_OPTION` / `TOOLBAR_TITLE`). + - Requires the host activity to use a `Theme.AppCompat` descendant when the toolbar is enabled. - New `IterableInAppDisplayMode` enum to control how in-app messages interact with system bars. Configure via `IterableConfig.Builder.setInAppDisplayMode()`: - `FORCE_EDGE_TO_EDGE` (default) — draws in-app content behind system bars with transparent status and navigation bars. This preserves the previous SDK behavior. - `FOLLOW_APP_LAYOUT` — matches the host app's system bar configuration automatically. @@ -20,6 +27,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Migration guide **No action required for most apps.** The default `FORCE_EDGE_TO_EDGE` preserves the existing behavior where in-app content draws behind system bars. +The inflated root of `IterableInboxFragment` is now a `LinearLayout` instead of a `RelativeLayout`. Subclasses that override `onCreateView` and cast `super.onCreateView(...)`'s return value to `RelativeLayout` should change the cast to `ViewGroup` (or `LinearLayout`). + If the close button in your fullscreen in-app messages is obscured by the status bar, you can fix it by choosing one of these modes: ```java diff --git a/iterableapi-ui/build.gradle b/iterableapi-ui/build.gradle index 77e6bb3eb..c8bc5f42f 100644 --- a/iterableapi-ui/build.gradle +++ b/iterableapi-ui/build.gradle @@ -40,6 +40,8 @@ android { enableAndroidTestCoverage true } } + + testOptions.unitTests.includeAndroidResources = true } dependencies { @@ -54,6 +56,9 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' testImplementation 'junit:junit:4.13.2' + testImplementation 'androidx.test:core:1.6.1' + testImplementation 'androidx.test.ext:junit:1.2.1' + testImplementation 'org.robolectric:robolectric:4.14.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' androidTestImplementation 'androidx.test:runner:1.6.2' androidTestImplementation 'androidx.test:rules:1.6.1' diff --git a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/InboxToolbarOption.kt b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/InboxToolbarOption.kt new file mode 100644 index 000000000..71df8d1af --- /dev/null +++ b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/InboxToolbarOption.kt @@ -0,0 +1,44 @@ +package com.iterable.iterableapi.ui.inbox + +import androidx.annotation.LayoutRes +import java.io.Serializable + +/** + * Controls how [IterableInboxToolbarView] renders. Passed as a fragment argument or + * intent extra on [IterableInboxFragment] / [IterableInboxActivity]. + */ +sealed interface InboxToolbarOption : Serializable { + + /** No toolbar. The fragment renders identically to prior SDK versions. */ + data object None : InboxToolbarOption { + private fun readResolve(): Any = None + } + + /** A title-only toolbar above the inbox list. */ + data object Default : InboxToolbarOption { + private fun readResolve(): Any = Default + } + + /** A toolbar with the configured title plus a back navigation icon. */ + data object WithBackButton : InboxToolbarOption { + private fun readResolve(): Any = WithBackButton + } + + /** + * Inflates a fully custom toolbar layout supplied by the integrator. The integrator + * owns all wiring for their own views (menus, clicks, icons, etc.). + * + * Reserved opt-in ids - the SDK looks up these ids via `findViewById` and, if + * present, auto-wires them. The names are deliberately namespaced; do not reuse + * them on unrelated views in the custom layout. Omitting either id keeps the SDK + * from touching that view. + * + * - `@id/iterable_reserved_inbox_toolbar_action` - auto-wired to the SDK's default + * back handler. Override the action by implementing + * [IterableInboxToolbarBackListener] on the host Activity or parent Fragment. + * - `@id/iterable_reserved_inbox_toolbar_title` - if the view is a `TextView`, the + * SDK sets its text to the `toolbarTitle` argument (or the default "Inbox" + * string when null). + */ + data class Custom(@LayoutRes val layoutRes: Int) : InboxToolbarOption +} diff --git a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxActivity.java b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxActivity.java index 990e7ef23..1a736f2cb 100644 --- a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxActivity.java +++ b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxActivity.java @@ -4,6 +4,7 @@ import android.os.Bundle; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.IntentCompat; import com.iterable.iterableapi.IterableConstants; import com.iterable.iterableapi.IterableLogger; @@ -11,6 +12,8 @@ import static com.iterable.iterableapi.ui.inbox.IterableInboxFragment.INBOX_MODE; import static com.iterable.iterableapi.ui.inbox.IterableInboxFragment.ITEM_LAYOUT_ID; +import static com.iterable.iterableapi.ui.inbox.IterableInboxFragment.TOOLBAR_OPTION; +import static com.iterable.iterableapi.ui.inbox.IterableInboxFragment.TOOLBAR_TITLE; /** * An activity wrapping {@link IterableInboxFragment} @@ -18,6 +21,11 @@ * Supports optional extras: * {@link IterableInboxFragment#INBOX_MODE} - {@link InboxMode} value with the inbox mode * {@link IterableInboxFragment#ITEM_LAYOUT_ID} - Layout resource id for inbox items + * {@link IterableInboxFragment#TOOLBAR_OPTION} - {@link InboxToolbarOption} variant for the opt-in inbox toolbar + * {@link IterableInboxFragment#TOOLBAR_TITLE} - Title shown in the opt-in inbox toolbar + * {@link IterableConstants#NO_MESSAGES_TITLE} - Title for the empty-inbox state + * {@link IterableConstants#NO_MESSAGES_BODY} - Body for the empty-inbox state + * {@link #ACTIVITY_TITLE} - Title set on the activity via {@code setTitle()} */ public class IterableInboxActivity extends AppCompatActivity { private static final String TAG = "IterableInboxActivity"; @@ -45,7 +53,14 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { noMessageTitle = extraBundle.getString(IterableConstants.NO_MESSAGES_TITLE, null); noMessageBody = extraBundle.getString(IterableConstants.NO_MESSAGES_BODY, null); } - inboxFragment = IterableInboxFragment.newInstance(inboxMode, itemLayoutId, noMessageTitle, noMessageBody); + + InboxToolbarOption toolbarOption = IntentCompat.getSerializableExtra(intent, TOOLBAR_OPTION, InboxToolbarOption.class); + if (toolbarOption == null) { + toolbarOption = InboxToolbarOption.None.INSTANCE; + } + String toolbarTitle = intent.getStringExtra(TOOLBAR_TITLE); + + inboxFragment = IterableInboxFragment.newInstance(inboxMode, itemLayoutId, noMessageTitle, noMessageBody, toolbarOption, toolbarTitle); if (intent.getStringExtra(ACTIVITY_TITLE) != null) { setTitle(intent.getStringExtra(ACTIVITY_TITLE)); diff --git a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxFragment.java b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxFragment.java index 4a970ebcd..ed49795ff 100644 --- a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxFragment.java +++ b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxFragment.java @@ -1,5 +1,6 @@ package com.iterable.iterableapi.ui.inbox; +import android.content.Context; import android.content.Intent; import android.graphics.Insets; import android.os.Build; @@ -7,6 +8,7 @@ import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.os.BundleCompat; import androidx.core.view.ViewCompat; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.LinearLayoutManager; @@ -15,7 +17,6 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.RelativeLayout; import android.widget.TextView; import com.iterable.iterableapi.InboxSessionManager; @@ -35,19 +36,29 @@ * The main class for Inbox UI. Renders the list of Inbox messages and handles touch interaction: * tap on an item opens the in-app message, swipe left deletes it. *
- * To customize the UI, either create the fragment with {@link #newInstance(InboxMode, int)}, - * or subclass {@link IterableInboxFragment} to use {@link #setAdapterExtension(IterableInboxAdapterExtension)}, + * To customize the UI, create the fragment with one of the {@code newInstance(...)} overloads + * (use {@link InboxToolbarOption} to opt into the built-in toolbar), or subclass + * {@link IterableInboxFragment} to use {@link #setAdapterExtension(IterableInboxAdapterExtension)}, * {@link #setComparator(IterableInboxComparator)} and {@link #setFilter(IterableInboxFilter)}. + * Implement {@link IterableInboxToolbarBackListener} on the host to handle toolbar back clicks. + *
+ * The host activity must use a {@code Theme.AppCompat} descendant when the opt-in + * toolbar is enabled. */ public class IterableInboxFragment extends Fragment implements IterableInAppManager.Listener, IterableInboxAdapter.OnListInteractionListener { private static final String TAG = "IterableInboxFragment"; public static final String INBOX_MODE = "inboxMode"; public static final String ITEM_LAYOUT_ID = "itemLayoutId"; + public static final String TOOLBAR_OPTION = "toolbarOption"; + public static final String TOOLBAR_TITLE = "toolbarTitle"; private InboxMode inboxMode = InboxMode.POPUP; private @LayoutRes int itemLayoutId = R.layout.iterable_inbox_item; private String noMessagesTitle; private String noMessagesBody; + private InboxToolbarOption toolbarOption = InboxToolbarOption.None.INSTANCE; + private @Nullable String toolbarTitle; + private @Nullable IterableInboxToolbarBackListener toolbarBackListener; TextView noMessagesTitleTextView; TextView noMessagesBodyTextView; RecyclerView recyclerView; @@ -93,6 +104,41 @@ public class IterableInboxFragment extends Fragment implements IterableInAppMana return inboxFragment; } + /** + * Create an Inbox fragment with toolbar customization; all other parameters use their defaults. + * + * @param toolbarOption Toolbar variant + * @param toolbarTitle Title shown in the toolbar, or null for the default "Inbox" string + * @return {@link IterableInboxFragment} instance + */ + @NonNull public static IterableInboxFragment newInstance( + @NonNull InboxToolbarOption toolbarOption, + @Nullable String toolbarTitle + ) { + return newInstance(InboxMode.POPUP, 0, null, null, toolbarOption, toolbarTitle); + } + + @NonNull public static IterableInboxFragment newInstance( + @NonNull InboxMode inboxMode, + @LayoutRes int itemLayoutId, + @Nullable String noMessagesTitle, + @Nullable String noMessagesBody, + @NonNull InboxToolbarOption toolbarOption, + @Nullable String toolbarTitle + ) { + IterableInboxFragment inboxFragment = new IterableInboxFragment(); + Bundle bundle = new Bundle(); + bundle.putSerializable(INBOX_MODE, inboxMode); + bundle.putInt(ITEM_LAYOUT_ID, itemLayoutId); + bundle.putString(IterableConstants.NO_MESSAGES_TITLE, noMessagesTitle); + bundle.putString(IterableConstants.NO_MESSAGES_BODY, noMessagesBody); + bundle.putSerializable(TOOLBAR_OPTION, toolbarOption); + bundle.putString(TOOLBAR_TITLE, toolbarTitle); + inboxFragment.setArguments(bundle); + + return inboxFragment; + } + /** * Set the inbox mode to display inbox messages either in a new activity or as an overlay * @@ -147,6 +193,23 @@ protected void setDateMapper(@NonNull IterableInboxDateMapper dateMapper) { } } + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + Fragment parent = getParentFragment(); + if (parent instanceof IterableInboxToolbarBackListener) { + toolbarBackListener = (IterableInboxToolbarBackListener) parent; + } else if (context instanceof IterableInboxToolbarBackListener) { + toolbarBackListener = (IterableInboxToolbarBackListener) context; + } + } + + @Override + public void onDetach() { + toolbarBackListener = null; + super.onDetach(); + } + @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -171,20 +234,41 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c if (arguments.getString(IterableConstants.NO_MESSAGES_BODY) != null) { noMessagesBody = arguments.getString(IterableConstants.NO_MESSAGES_BODY); } + InboxToolbarOption toolbarOptionArg = BundleCompat.getSerializable(arguments, TOOLBAR_OPTION, InboxToolbarOption.class); + if (toolbarOptionArg != null) { + toolbarOption = toolbarOptionArg; + } + if (arguments.getString(TOOLBAR_TITLE) != null) { + toolbarTitle = arguments.getString(TOOLBAR_TITLE); + } + } + + ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.iterable_inbox_fragment, container, false); + + IterableInboxToolbarView toolbar = rootView.findViewById(R.id.iterable_inbox_toolbar); + toolbar.apply(toolbarOption, toolbarTitle); + // Prefer the host listener if one was discovered in onAttach; otherwise delegate + // to the fragment's host activity so we never depend on the view's Context chain + // to find a ComponentActivity. + if (toolbarBackListener != null) { + toolbar.setOnBackClickListener(v -> toolbarBackListener.onInboxToolbarBackClick()); + } else { + toolbar.setOnBackClickListener(v -> + requireActivity().getOnBackPressedDispatcher().onBackPressed() + ); } - RelativeLayout relativeLayout = (RelativeLayout) inflater.inflate(R.layout.iterable_inbox_fragment, container, false); - recyclerView = relativeLayout.findViewById(R.id.list); + recyclerView = rootView.findViewById(R.id.list); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); IterableInboxAdapter adapter = new IterableInboxAdapter(IterableApi.getInstance().getInAppManager().getInboxMessages(), IterableInboxFragment.this, adapterExtension, comparator, filter, dateMapper); recyclerView.setAdapter(adapter); - noMessagesTitleTextView = relativeLayout.findViewById(R.id.emptyInboxTitle); - noMessagesBodyTextView = relativeLayout.findViewById(R.id.emptyInboxMessage); + noMessagesTitleTextView = rootView.findViewById(R.id.emptyInboxTitle); + noMessagesBodyTextView = rootView.findViewById(R.id.emptyInboxMessage); noMessagesTitleTextView.setText(noMessagesTitle); noMessagesBodyTextView.setText(noMessagesBody); ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new IterableInboxTouchHelper(getContext(), adapter)); itemTouchHelper.attachToRecyclerView(recyclerView); - return relativeLayout; + return rootView; } @Override diff --git a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarBackListener.java b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarBackListener.java new file mode 100644 index 000000000..0cac6282d --- /dev/null +++ b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarBackListener.java @@ -0,0 +1,18 @@ +package com.iterable.iterableapi.ui.inbox; + +/** + * Implement on the host Activity or parent Fragment of {@link IterableInboxFragment} + * to handle back-navigation taps from the opt-in inbox toolbar. + * + *
Relevant when toolbar option is {@code InboxToolbarOption.WithBackButton} or a + * {@code InboxToolbarOption.Custom} layout includes a view with id + * {@code @id/iterable_reserved_inbox_toolbar_action}. If no host implements this + * interface, the fragment falls back to the host activity's + * {@code OnBackPressedDispatcher}. + * + *
The listener is discovered during {@code onAttach()}, so it survives process
+ * death - recreated fragments re-bind to the restored host automatically.
+ */
+public interface IterableInboxToolbarBackListener {
+ void onInboxToolbarBackClick();
+}
diff --git a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarView.kt b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarView.kt
new file mode 100644
index 000000000..e40ff38ed
--- /dev/null
+++ b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarView.kt
@@ -0,0 +1,109 @@
+package com.iterable.iterableapi.ui.inbox
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.activity.ComponentActivity
+import androidx.annotation.LayoutRes
+import com.google.android.material.appbar.MaterialToolbar
+import com.iterable.iterableapi.ui.R
+
+/**
+ * Opt-in toolbar for [IterableInboxFragment]. Configure via [apply] with an
+ * [InboxToolbarOption].
+ *
+ * The view is empty until [apply] is called with a non-`None` option, so the
+ * `None` default does not inflate any Material widgets.
+ *
+ * **Theme requirement:** when a non-`None` option is applied, the host activity must
+ * use a `Theme.AppCompat` descendant - `MaterialToolbar` will throw an
+ * `InflateException` otherwise. If using the [IterableInboxActivity] this is a non-concern.
+ */
+class IterableInboxToolbarView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : FrameLayout(context, attrs, defStyleAttr) {
+
+ private var materialToolbar: MaterialToolbar? = null
+ private var backClickListener: OnClickListener? = null
+ private var isCustomLayout = false
+
+ /** Configure the toolbar. Safe to call multiple times (e.g. on config change). */
+ fun apply(option: InboxToolbarOption, title: String?) {
+ when (option) {
+ InboxToolbarOption.None -> {
+ visibility = View.GONE
+ // Intentionally do not inflate. `None` is the default and must not
+ // require an AppCompat-derived theme on the host.
+ }
+ InboxToolbarOption.Default -> {
+ showDefaultLayout()
+ visibility = View.VISIBLE
+ materialToolbar?.title = resolveTitle(title)
+ materialToolbar?.navigationIcon = null
+ materialToolbar?.setNavigationOnClickListener(null)
+ }
+ InboxToolbarOption.WithBackButton -> {
+ showDefaultLayout()
+ visibility = View.VISIBLE
+ materialToolbar?.title = resolveTitle(title)
+ materialToolbar?.setNavigationIcon(R.drawable.iterable_ic_arrow_back_24dp)
+ materialToolbar?.setNavigationOnClickListener { v -> dispatchBackClick(v) }
+ }
+ is InboxToolbarOption.Custom -> {
+ showCustomLayout(option.layoutRes)
+ visibility = View.VISIBLE
+ findViewById