TRTomas RainboltFrontend + Product
All work

PopHops / Mobile experience

Shared content.Native behavior.

A connected experience, shaped for the device.

02 / Mobile

Device context. Clear feedback.

Carry the experience forward. Respect the device.

The builder organizes content for an operator; the app presents it for someone choosing and using an experience. Photography, location details, and available value take priority over configuration controls.

Shared content keeps the experience connected, but device permissions add their own states. A denied request, limited access, and permission granted need different explanations and next actions.

Permission denied. A path forward.

The permission flow derives its presentation from a snapshot of device access. Granted access dismisses the prompt; limited access receives its own explanation; a blocked request offers a route to Settings. Request and refresh actions update the same state source.

This keeps recovery part of the interaction design. The remaining constraint is platform behavior: the flow still needs verification on real devices and after returning from Settings.

React Native + TypeScript: Permission state derived from a normalized snapshot

Permission state derived from a normalized snapshot

The hook separates device actions from the state used to choose a message. Components consume a resolved variant instead of rebuilding permission rules in each screen.

Adapted from the mobile permission flow that keeps modal state, OS prompts, and store snapshots in sync. This excerpt omits surrounding application code.

React Native + TypeScriptCurated sample
type PermissionStatus = 'prompt' | 'limited' | 'granted' | 'denied';
type PermissionVariant = 'none' | 'request' | 'limited' | 'blocked';

type PermissionSnapshot = {
  status: PermissionStatus;
  canAskAgain: boolean;
};

export function usePermissionFlow(
  key: PermissionType,
  options?: UsePermissionFlowOptions
): UsePermissionFlowResult {
  const snapshot = usePermissionSnapshot(key);
  const { refreshPermission, setSnapshot } = usePermissionsActions();
  const hasBeenDenied = previouslyDenied[key] === true;

  React.useEffect(() => {
    if (snapshot.status === 'denied') {
      previouslyDenied[key] = true;
    }
  }, [key, snapshot.status]);

  const refresh = React.useCallback(async () => {
    const next = await refreshPermission(key);

    if (next.isDenied) {
      previouslyDenied[key] = true;
    }

    return next;
  }, [key, refreshPermission]);

  const request = React.useCallback(async () => {
    const next = await permissionsService.request(key);

    setSnapshot(next);

    if (next.isDenied) {
      previouslyDenied[key] = true;
    }

    void refreshPermission(key);
    return next;
  }, [key, refreshPermission, setSnapshot]);

  const openSettings = React.useCallback(async () => {
    if (typeof Linking.openSettings === 'function') {
      await Linking.openSettings();
      return;
    }

    if (Platform.OS === 'android') {
      await Linking.openURL('app-settings:');
    }
  }, []);

  const modalVariant = React.useMemo(() => resolveVariant(snapshot, hasBeenDenied), [hasBeenDenied, snapshot]);

  return {
    key,
    snapshot,
    refresh,
    request,
    openSettings,
    modalVariant,
    content: modalVariant === 'none' ? null : getContentFor(key, modalVariant)
  };
}

function resolveVariant(snapshot: PermissionSnapshot, hasBeenDenied: boolean): PermissionVariant {
  if (snapshot.status === 'granted') {
    return 'none';
  }

  if (snapshot.status === 'limited') {
    return 'limited';
  }

  if (snapshot.status === 'denied' && !snapshot.canAskAgain && hasBeenDenied) {
    return 'blocked';
  }

  return 'request';
}