// app/store.jsx — UI runtime. Talks to the backend ONLY through window.LCAdapter.
//
// Contains NO recipe data, NO seed content, NO domain rules — those live in the
// adapter (data) and domain.js (presentation). This file owns React state,
// navigation, and the device/cook-timer UI concerns that are prototype-side only.
//
// The useApp() surface below is the stable contract every screen consumes; it
// does not change when the data source flips from mock to live.
//
// Depends on: window.LCAdapter (adapter.js), window.LCDomain (domain.js)
const { createContext, useContext, useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } = React;
const AppCtx = createContext(null);
const useApp = () => useContext(AppCtx);
// device → responsive layout mode + on-screen pixel size (prototype framing only)
const DEVICE_LAYOUT = { laptop: 'desktop', ipad: 'tablet', iphone: 'mobile' };
// In the SHIPPED app (live mode) there is no device picker — layout follows the
// real viewport. These breakpoints mirror the three prototype sizes.
const IS_LIVE = (typeof window !== 'undefined') && window.LC_ADAPTER_MODE === 'live';
function layoutForWidth(w) {
if (w >= 1080) return 'desktop';
if (w >= 700) return 'tablet';
return 'mobile';
}
function useViewportLayout(active) {
const get = () => layoutForWidth(typeof window !== 'undefined' ? window.innerWidth : 1440);
const [vp, setVp] = useState(get);
useEffect(() => {
if (!active) return;
const onResize = () => setVp(get());
onResize();
window.addEventListener('resize', onResize);
window.addEventListener('orientationchange', onResize);
return () => {
window.removeEventListener('resize', onResize);
window.removeEventListener('orientationchange', onResize);
};
}, [active]);
return vp;
}
const DEVICE_SCREEN = {
laptop: { w: 1440, h: 900 },
ipad: { w: 1180, h: 820 },
iphone: { w: 390, h: 844 },
};
// ── Session = UI runtime state (prototype-only; never sent to the backend) ──
const SESSION_KEY = 'luiscooks.session.v2';
const SESSION_DEFAULT = {
device: null, loggedIn: false,
screen: 'login', params: {}, history: [], navTab: 'recipes',
search: '', filter: 'all', sort: 'recent',
timers: [],
};
const SESSION_KEYS = Object.keys(SESSION_DEFAULT);
// ── Data = per-user state, sourced from the adapter (mock or live) ──
const DATA_DEFAULT = {
recipes: [],
favourites: [], notesByRecipe: {},
checkedByRecipe: {}, cookStepByRecipe: {},
plan: {}, shoppingRecipes: [], shoppingChecked: [], customRecipes: [],
settings: { unitSystem: 'metric', defaultPlanningServings: 2 }, ingredientCosts: [],
loginError: null, // transient; not persisted (lives outside SESSION_KEYS)
};
function loadSession() {
try {
const raw = localStorage.getItem(SESSION_KEY);
const saved = raw ? JSON.parse(raw) : {};
const out = { ...SESSION_DEFAULT };
SESSION_KEYS.forEach((k) => { if (saved[k] !== undefined) out[k] = saved[k]; });
return out;
} catch (e) { return { ...SESSION_DEFAULT }; }
}
function saveSession(state) {
try {
const out = {};
SESSION_KEYS.forEach((k) => { out[k] = state[k]; });
localStorage.setItem(SESSION_KEY, JSON.stringify(out));
} catch (e) {}
}
const upsertById = (list, item) => {
const exists = (list || []).some((x) => x.id === item.id);
return exists ? list.map((x) => (x.id === item.id ? item : x)) : [item, ...(list || [])];
};
function AppProvider({ children }) {
const adapter = window.LCAdapter;
const [state, setState] = useState(() => ({ ...DATA_DEFAULT, ...loadSession() }));
const [ref, setRef] = useState({
WEEK_DAYS: [], AI_INPUTS: [], PLAN_ALTERNATIVES: [], SCAN_ITEMS: [], SHARED_KITCHENS: [],
});
const [dataLoaded, setDataLoaded] = useState(false);
// ── bootstrap the signed-in snapshot once on mount ──
// A failed boot must NEVER trap the app on the splash. The live API
// now returns an anonymous-safe empty shell (200) before login, so the common
// pre-login case no longer 401s — but we still defend here: on any failure we
// set dataLoaded so the app renders, and on a 401 we drop a stale/expired token
// and fall back to the login screen instead of a broken signed-in state.
const bootstrap = useCallback(async () => {
let snap;
try {
snap = await adapter.bootstrap();
} catch (e) {
if (/\b401\b/.test(String((e && e.message) || ''))) {
try { window.localStorage.removeItem('luiscooks.token'); } catch (_) {}
setState((s) => ({ ...s, loggedIn: false, screen: 'login', params: {}, history: [] }));
}
setDataLoaded(true); // render the app (login screen) rather than hang on splash
return;
}
setState((s) => ({
...s,
recipes: snap.recipes || [],
favourites: snap.favourites || [],
notesByRecipe: snap.notes || {},
checkedByRecipe: snap.checkedByRecipe || {},
cookStepByRecipe: snap.cookStepByRecipe || {},
plan: snap.plan || {},
shoppingRecipes: snap.shoppingRecipes || [],
shoppingChecked: snap.shoppingChecked || [],
customRecipes: snap.customRecipes || [],
settings: snap.settings || { unitSystem: 'metric', defaultPlanningServings: 2 },
ingredientCosts: snap.ingredientCosts || [],
}));
setRef({
WEEK_DAYS: snap.weekDays || [],
AI_INPUTS: snap.planInputs || [],
PLAN_ALTERNATIVES: snap.planAlternatives || [],
SCAN_ITEMS: snap.scanItems || [],
SHARED_KITCHENS: snap.sharedKitchens || [],
});
setDataLoaded(true);
}, [adapter]);
useEffect(() => { bootstrap(); }, [bootstrap]);
// persist ONLY the session subset
useEffect(() => { saveSession(state); },
[state.device, state.loggedIn, state.screen, state.params, state.history,
state.navTab, state.search, state.filter, state.sort, state.timers]);
const set = useCallback((patch) => {
setState((s) => ({ ...s, ...(typeof patch === 'function' ? patch(s) : patch) }));
}, []);
// ── recipes ──
const allRecipes = state.recipes;
const getRecipe = useCallback((id) =>
(state.recipes || []).find((r) => r.id === id) || null, [state.recipes]);
const photoFor = useCallback((id) => {
const r = (state.recipes || []).find((x) => x.id === id);
return r ? (r.photoUrl || null) : null;
}, [state.recipes]);
// ── navigation (pure UI) ──
// Scroll handling: forward nav (go/openRecipe/setTab) starts the new screen at
// the top; `back` restores the scroll position of the screen you're returning
// to, so casually browsing the library and dipping into a recipe doesn't lose
// your place. The outgoing scrollTop is stashed on the history entry and
// re-applied on the way back.
const historyRef = useRef(state.history);
historyRef.current = state.history;
const getScroll = () => {
const sc = document.querySelector('[data-app-scroll]');
return sc ? sc.scrollTop : 0;
};
const scrollTop = () => requestAnimationFrame(() => {
const sc = document.querySelector('[data-app-scroll]');
if (sc) sc.scrollTop = 0;
});
// `back` stashes the scroll position to restore here; a useLayoutEffect applies
// it synchronously BEFORE the browser paints the destination screen, so there's
// no flash of the top-of-list before it jumps to where you were.
const pendingScroll = useRef(null);
const go = useCallback((screen, params = {}) => {
const scroll = getScroll();
setState((s) => ({ ...s,
history: [...s.history, { screen: s.screen, params: s.params, navTab: s.navTab, scroll }],
screen, params }));
scrollTop();
}, []);
const back = useCallback(() => {
const hist = historyRef.current || [];
if (!hist.length) return;
const prev = hist[hist.length - 1];
pendingScroll.current = prev.scroll || 0;
setState((s) => ({ ...s, screen: prev.screen, params: prev.params,
navTab: prev.navTab ?? s.navTab, history: s.history.slice(0, -1) }));
}, []);
const TAB_SCREEN = { recipes: 'library', cook: 'scan', plan: 'plan', groceries: 'shopping' };
const setTab = useCallback((tab) => {
setState((s) => ({ ...s, navTab: tab, screen: TAB_SCREEN[tab] || 'library', params: {}, history: [] }));
scrollTop();
}, []);
const openRecipe = useCallback((id, opts = {}) => {
const scroll = getScroll();
setState((s) => ({ ...s,
history: [...s.history, { screen: s.screen, params: s.params, navTab: s.navTab, scroll }],
screen: 'recipe', params: { recipeId: id, ...opts } }));
scrollTop();
}, []);
// Apply a pending restore before paint. Runs synchronously after the new
// screen's DOM is committed; setting scrollTop here (vs a timeout/rAF) means
// the very first painted frame is already at the right position — no flicker.
// (Idempotent under StrictMode's double-invoke: it just sets the same value.)
useLayoutEffect(() => {
if (pendingScroll.current == null) return;
const y = pendingScroll.current;
pendingScroll.current = null;
const sc = document.querySelector('[data-app-scroll]');
if (sc) sc.scrollTop = y;
}, [state.screen, state.params]);
// ── device (prototype framing) ──
const pickDevice = useCallback((device) => setState((s) => ({ ...s, device })), []);
const exitDevice = useCallback(() => setState((s) => ({ ...s, device: null })), []);
// ── auth ──
// Bearer auth is enforced live: a wrong token makes adapter.login REJECT (the
// 401 throws), so we must only mark loggedIn when the call RESOLVES. Gating on
// "didn't throw / ok !== false" is the right signal in both modes — live throws
// on a bad token; the mock always resolves, so the offline demo keeps working.
const login = useCallback(async (token) => {
let r = null;
try { r = await adapter.login(token); } catch (e) { r = null; }
if (!r || r.ok === false) {
setState((s) => ({ ...s, loginError: "That token didn't work \u2014 check it and try again." }));
return;
}
await bootstrap();
setState((s) => ({ ...s, loggedIn: true, loginError: null, screen: 'library', navTab: 'recipes', params: {}, history: [] }));
}, [adapter, bootstrap]);
const logout = useCallback(async () => {
try { await adapter.logout(); } catch (e) {}
setState((s) => ({ ...s, loggedIn: false, screen: 'login', params: {}, history: [] }));
}, [adapter]);
// ── favourites ──
const isFav = useCallback((id) => (state.favourites || []).includes(id), [state.favourites]);
const toggleFav = useCallback((id) => {
setState((s) => {
const has = s.favourites.includes(id);
const next = has ? s.favourites.filter((x) => x !== id) : [...s.favourites, id];
adapter.setFavourite(id, !has);
return { ...s, favourites: next };
});
}, [adapter]);
// ── ratings ──
// Rating is a single field on the recipe now (backend consolidated it onto
// Recipe.rating; bootstrap.ratings is deprecated/empty — see
// docs/design-handoff/ratings-single-field.md). setRating keeps the local
// recipe fresh so the editor/planner reflect a just-set rating without a refresh.
const ratingFor = useCallback((r) => (r && r.rating) || 0, []);
const setRating = useCallback((id, v) => {
setState((s) => ({ ...s,
recipes: s.recipes.map((r) => (r.id === id ? { ...r, rating: v } : r)),
customRecipes: s.customRecipes.map((r) => (r.id === id ? { ...r, rating: v } : r)) }));
adapter.setRating(id, v);
}, [adapter]);
// ── notes ──
const noteFor = useCallback((r) =>
state.notesByRecipe[r.id] != null ? state.notesByRecipe[r.id] : r.notes, [state.notesByRecipe]);
const setNote = useCallback((id, v) => {
setState((s) => ({ ...s, notesByRecipe: { ...s.notesByRecipe, [id]: v } }));
adapter.setNote(id, v);
}, [adapter]);
// ── cook-mode ingredient check ──
const checkedFor = useCallback((id) => new Set(state.checkedByRecipe[id] || []), [state.checkedByRecipe]);
const toggleChecked = useCallback((id, line) => {
setState((s) => {
const cur = new Set(s.checkedByRecipe[id] || []);
cur.has(line) ? cur.delete(line) : cur.add(line);
const lines = [...cur];
adapter.setChecked(id, lines);
return { ...s, checkedByRecipe: { ...s.checkedByRecipe, [id]: lines } };
});
}, [adapter]);
const cookStep = useCallback((id) => state.cookStepByRecipe[id] || 0, [state.cookStepByRecipe]);
const setCookStep = useCallback((id, idx) => {
setState((s) => ({ ...s, cookStepByRecipe: { ...s.cookStepByRecipe, [id]: idx } }));
adapter.setCookStep(id, idx);
}, [adapter]);
// ── timers (count-up · ephemeral UI, prototype-side only) ──
const addTimer = useCallback((label) => {
setState((s) => ({ ...s, timers: [...s.timers, {
id: 't' + Date.now(), label: label || 'Timer', base: 0, startedAt: Date.now(), running: true,
}] }));
}, []);
const toggleTimer = useCallback((id) => {
setState((s) => ({ ...s, timers: s.timers.map((t) => {
if (t.id !== id) return t;
if (t.running) return { ...t, running: false, base: t.base + (Date.now() - t.startedAt) / 1000, startedAt: null };
return { ...t, running: true, startedAt: Date.now() };
}) }));
}, []);
const removeTimer = useCallback((id) => {
setState((s) => ({ ...s, timers: s.timers.filter((t) => t.id !== id) }));
}, []);
// ── shopping ──
const addRecipeToShopping = useCallback((id) => {
setState((s) => s.shoppingRecipes.includes(id) ? s
: { ...s, shoppingRecipes: [...s.shoppingRecipes, id] });
adapter.addShoppingRecipe(id);
}, [adapter]);
const removeRecipeFromShopping = useCallback((id) => {
setState((s) => ({ ...s, shoppingRecipes: s.shoppingRecipes.filter((x) => x !== id) }));
adapter.removeShoppingRecipe(id);
}, [adapter]);
const toggleShopItem = useCallback((key) => {
setState((s) => {
const has = s.shoppingChecked.includes(key);
const next = has ? s.shoppingChecked.filter((x) => x !== key) : [...s.shoppingChecked, key];
adapter.setShoppingChecked(next);
return { ...s, shoppingChecked: next };
});
}, [adapter]);
// ── plan ──
const setPlanDay = useCallback((day, entry) => {
setState((s) => ({ ...s, plan: { ...s.plan, [day]: entry } }));
adapter.setPlanDay(day, entry);
}, [adapter]);
const regeneratePlan = useCallback(async () => {
const plan = await adapter.regeneratePlan();
if (plan) setState((s) => ({ ...s, plan }));
}, [adapter]);
const autoSchedule = useCallback(async (opts) => {
const res = await adapter.autoSchedule(opts);
if (res && res.plan) setState((s) => ({ ...s, plan: res.plan }));
return res;
}, [adapter]);
// ── recipe CRUD ──
const saveRecipe = useCallback((recipe) => {
// optimistic insert so the recipe screen finds it immediately
setState((s) => ({ ...s,
recipes: upsertById(s.recipes, recipe),
customRecipes: upsertById(s.customRecipes, recipe) }));
Promise.resolve(adapter.saveRecipe(recipe)).then((saved) => {
if (saved) setState((s) => ({ ...s, recipes: upsertById(s.recipes, saved) }));
}).catch(() => {});
}, [adapter]);
// ── photo enhancement (AI) ──
// enhancePhoto kicks off the job and polls getRecipe() until the backend
// reports done/failed; revertPhoto discards the AI photo. All state lives on
// the recipe object (enhancementStatus / photoUrl / originalPhotoUrl), so the
// hero re-renders automatically as status changes.
const pollers = useRef({});
const applyRecipe = useCallback((saved) => {
if (saved) setState((s) => ({ ...s, recipes: upsertById(s.recipes, saved) }));
}, []);
const stopPoll = useCallback((id) => {
if (pollers.current[id]) { clearInterval(pollers.current[id]); delete pollers.current[id]; }
}, []);
const pollEnhance = useCallback((id) => {
if (pollers.current[id]) return;
pollers.current[id] = setInterval(async () => {
try {
const r = await adapter.getRecipe(id);
if (r) { applyRecipe(r); if (r.enhancementStatus !== 'enhancing') stopPoll(id); }
} catch (e) {}
}, 1200);
}, [adapter, applyRecipe, stopPoll]);
const enhancePhoto = useCallback(async (id, opts = {}) => {
setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id
? { ...r, enhancementStatus: 'enhancing', enhancementError: null } : r) }));
try {
const r = await adapter.enhancePhoto(id, opts);
if (r) applyRecipe(r);
pollEnhance(id);
} catch (e) {
setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id
? { ...r, enhancementStatus: 'failed', enhancementError: 'Could not start — try again.' } : r) }));
}
}, [adapter, applyRecipe, pollEnhance]);
const revertPhoto = useCallback(async (id) => {
stopPoll(id);
try { const r = await adapter.revertPhoto(id); if (r) applyRecipe(r); } catch (e) {}
}, [adapter, applyRecipe, stopPoll]);
useEffect(() => () => { Object.values(pollers.current).forEach(clearInterval); }, []);
// ── recipe costing (AI parse + price DB) ──
// calculateCost kicks off the job and polls getRecipe() until the backend
// reports costed/needs_attention; mirrors the enhance flow. All state lives on
// the recipe (costStatus / costPerServing / fullCost / costBreakdown), so the
// cost UI re-renders as status changes.
const costPollers = useRef({});
const stopCostPoll = useCallback((id) => {
if (costPollers.current[id]) { clearInterval(costPollers.current[id]); delete costPollers.current[id]; }
}, []);
const pollCost = useCallback((id) => {
if (costPollers.current[id]) return;
costPollers.current[id] = setInterval(async () => {
try {
const r = await adapter.getRecipe(id);
if (r) { applyRecipe(r); if (r.costStatus !== 'costing') stopCostPoll(id); }
} catch (e) {}
}, 1000);
}, [adapter, applyRecipe, stopCostPoll]);
const calculateCost = useCallback(async (id, opts = {}) => {
setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id
? { ...r, costStatus: 'costing' } : r) }));
try {
const r = await adapter.calculateCost(id, opts);
if (r) applyRecipe(r);
pollCost(id);
} catch (e) {
setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id
? { ...r, costStatus: r.costStatus === 'costing' ? 'none' : r.costStatus } : r) }));
}
}, [adapter, applyRecipe, pollCost]);
useEffect(() => () => { Object.values(costPollers.current).forEach(clearInterval); }, []);
// ── recipe effort estimate (on-demand AI) ──
// estimateEffort kicks off the job and polls getRecipe() until effortStatus
// leaves 'estimating'; mirrors the cost/enhance flow. State lives on the recipe
// (effortStatus / effortMinutes / effort / effortEstimated).
const effortPollers = useRef({});
const stopEffortPoll = useCallback((id) => {
if (effortPollers.current[id]) { clearInterval(effortPollers.current[id]); delete effortPollers.current[id]; }
}, []);
const pollEffort = useCallback((id) => {
if (effortPollers.current[id]) return;
effortPollers.current[id] = setInterval(async () => {
try {
const r = await adapter.getRecipe(id);
if (r) { applyRecipe(r); if (r.effortStatus !== 'estimating') stopEffortPoll(id); }
} catch (e) {}
}, 1000);
}, [adapter, applyRecipe, stopEffortPoll]);
const estimateEffort = useCallback(async (id) => {
setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id
? { ...r, effortStatus: 'estimating' } : r) }));
try {
const r = await adapter.estimateEffort(id);
if (r) applyRecipe(r);
pollEffort(id);
} catch (e) {
setState((s) => ({ ...s, recipes: s.recipes.map((r) => r.id === id
? { ...r, effortStatus: r.effortMinutes != null ? 'done' : 'none' } : r) }));
}
}, [adapter, applyRecipe, pollEffort]);
useEffect(() => () => { Object.values(effortPollers.current).forEach(clearInterval); }, []);
// ── settings ──
const settings = state.settings || { unitSystem: 'metric', defaultPlanningServings: 2 };
const setSettings = useCallback((patch) => {
setState((s) => ({ ...s, settings: { ...s.settings, ...patch } }));
adapter.setSettings(patch);
}, [adapter]);
// ── ingredient-price DB (Settings ▸ ingredient costs) ──
const saveIngredientCost = useCallback(async (item) => {
const saved = await adapter.saveIngredientCost(item);
if (saved) setState((s) => {
const exists = (s.ingredientCosts || []).some((x) => x.key === saved.key);
return { ...s, ingredientCosts: exists
? s.ingredientCosts.map((x) => (x.key === saved.key ? saved : x))
: [saved, ...(s.ingredientCosts || [])] };
});
return saved;
}, [adapter]);
const deleteIngredientCost = useCallback((key) => {
setState((s) => ({ ...s, ingredientCosts: (s.ingredientCosts || []).filter((x) => x.key !== key) }));
adapter.deleteIngredientCost(key);
}, [adapter]);
// ── plan: per-meal planned servings (Phase 2 UI) ──
const setPlanServings = useCallback((day, n) => {
setState((s) => {
const e = s.plan[day];
if (!e || e.skip || !e.recipeId) return s;
const entry = { ...e, plannedServings: Math.max(1, n) };
adapter.setPlanDay(day, entry);
return { ...s, plan: { ...s.plan, [day]: entry } };
});
}, [adapter]);
// ── fridge scan helpers (AI endpoints) ──
const scanFridge = useCallback(() => adapter.scanFridge(), [adapter]);
const matchRecipes = useCallback((items) => adapter.matchRecipes(items), [adapter]);
// ── reset (prototype convenience) ──
const resetAll = useCallback(async () => {
try { await adapter.resetDemo(); } catch (e) {}
try { localStorage.removeItem(SESSION_KEY); } catch (e) {}
setState((s) => ({ ...DATA_DEFAULT, ...SESSION_DEFAULT, device: s.device }));
await bootstrap();
}, [adapter, bootstrap]);
const viewportLayout = useViewportLayout(IS_LIVE);
// Live (shipped): follow the real viewport. Prototype: follow the chosen device.
const layout = IS_LIVE
? viewportLayout
: (state.device ? DEVICE_LAYOUT[state.device] : 'desktop');
const value = {
state, set, layout, device: state.device, dataLoaded, isLive: IS_LIVE,
DEVICE_SCREEN, DEVICE_LAYOUT,
allRecipes, getRecipe, photoFor,
go, back, setTab, openRecipe, pickDevice, exitDevice, login, logout,
isFav, toggleFav, ratingFor, setRating, noteFor, setNote,
checkedFor, toggleChecked, cookStep, setCookStep,
addTimer, toggleTimer, removeTimer,
addRecipeToShopping, removeRecipeFromShopping, toggleShopItem,
setPlanDay, regeneratePlan, autoSchedule, saveRecipe, resetAll,
enhancePhoto, revertPhoto,
calculateCost, settings, setSettings, ingredientCosts: state.ingredientCosts,
saveIngredientCost, deleteIngredientCost, setPlanServings,
estimateEffort,
scanFridge, matchRecipes,
// reference data (from bootstrap)
WEEK_DAYS: ref.WEEK_DAYS, AI_INPUTS: ref.AI_INPUTS, PLAN_ALTERNATIVES: ref.PLAN_ALTERNATIVES,
SCAN_ITEMS: ref.SCAN_ITEMS, SHARED_KITCHENS: ref.SHARED_KITCHENS,
// presentation helpers (ship)
parseIngredientGroups: window.LCDomain.parseIngredientGroups,
buildShoppingList: window.LCDomain.buildShoppingList,
costInfo: window.LCDomain.costInfo,
money: window.LCDomain.money,
costForServings: window.LCDomain.costForServings,
scaleQuantities: window.LCDomain.scaleQuantities,
};
return {children};
}
// ── live clock hook: re-render every second (for ticking timers) ──
function useNow(active) {
const [, force] = useState(0);
useEffect(() => {
if (!active) return;
const id = setInterval(() => force((n) => n + 1), 1000);
return () => clearInterval(id);
}, [active]);
return Date.now();
}
function timerElapsed(t, now) {
const base = t.base || 0;
return Math.floor(base + (t.running && t.startedAt ? (now - t.startedAt) / 1000 : 0));
}
function fmtTime(secs) {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
Object.assign(window, {
AppProvider, useApp, useNow, timerElapsed, fmtTime,
DEVICE_LAYOUT, DEVICE_SCREEN, IS_LIVE,
});