Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions iterableapi-ui/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ android {
enableAndroidTestCoverage true
}
}

testOptions.unitTests.includeAndroidResources = true
}

dependencies {
Expand All @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,28 @@
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;
import com.iterable.iterableapi.ui.R;

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}
* <p>
* 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";
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.iterable.iterableapi.ui.inbox;

import android.content.Context;
import android.content.Intent;
import android.graphics.Insets;
import android.os.Build;
import android.os.Bundle;
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;
Expand All @@ -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;
Expand All @@ -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.
* <p>
* 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.
* <p>
* 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;
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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}.
*
* <p>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();
}
Loading
Loading