diff --git a/README.md b/README.md index 01bfc8a..9570b0c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ An iFruit account is optional. Unlinked devices retain local settings, alarms, m ## Requirements - ESX Legacy (`es_extended`), Qbox (`qbx_core`), or QBCore (`qb-core`). The bridge selects a running supported framework when `Config.Bridge.Framework` is set to `"auto"`. -- A supported metadata inventory: `ox_inventory`, `qb-inventory`, `lj-inventory`, `qs-inventory`, `codem-inventory`, `core_inventory`, or `mf-inventory`. The bridge auto-detects a running provider and normalizes `metadata`/`info`, slots, counts, item mutations, and usable-item callbacks. `mf-inventory` requires ESX. +- A supported inventory: `ox_inventory`, `qb-inventory`, `lj-inventory`, `qs-inventory`, `codem-inventory`, `core_inventory`, `mf-inventory`, or `smx-inventory`. The bridge auto-detects a running provider and normalizes metadata, slots, counts, item mutations, and usable-item callbacks. `mf-inventory` and `smx-inventory` require ESX. Because SMX stores standard ESX items as stacks, its adapter persists one active Phone/SIM metadata record per player and item type in ESX player metadata. - A non-stackable inventory item named `sky_phone`. - Two unique, non-stackable inventory items named `sky_phone_sim_registered` and `sky_phone_sim_anonymous`. Their metadata is initialized automatically on first use, so shops and crafting recipes add plain items without supplying a number. - `oxmysql` with MySQL/MariaDB. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9daa55c..f6e415c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -150,9 +150,35 @@ function hydratePhone(payload: PhoneOpenPayload): void { void calls.bootstrap() } +async function hydrateDevelopmentPhone(): Promise { + const response = await nuiCall('development:bootstrap') + if (response.success && response.data) { + hydratePhone(response.data) + return + } + + hydratePhone({ + account: null, + device: { + data: {}, + imei: '356938035643809', + name: 'iFruit Phone', + sim: { + id: 'development-sim', + number: '5551234567', + registered: true, + type: 'registered', + }, + }, + notes: [], + token: 'development', + }) +} + function onMessage(event: MessageEvent): void { if (event.data?.type === 'app:open') { hydratePhone(event.data.data as PhoneOpenPayload) + void nuiCall('ui:opened') } else if (event.data?.type === 'device:updated') { hydratePhone(event.data.data as PhoneOpenPayload) } else if (event.data?.type === 'app:close') { @@ -193,15 +219,23 @@ function onMessage(event: MessageEvent): void { } else if (event.data?.type === 'marketplace:changed' && event.data.data) { const data = event.data.data as MarketplaceEventData if (data.counts) marketplace.setCounts(data.counts) - } else if (event.data?.type === 'marketplace:new-message' && event.data.data) { + } else if ( + event.data?.type === 'marketplace:new-message' && + event.data.data + ) { const data = event.data.data as MarketplaceEventData const notification: PhoneNotificationInput = { appId: 'citymarkt', subtitle: data.sender, - text: data.text ?? phone.t('Apps.citymarkt.newMessage', { sender: data.sender ?? '' }), + text: + data.text ?? + phone.t('Apps.citymarkt.newMessage', { sender: data.sender ?? '' }), title: data.title ?? phone.t('Apps.citymarkt.name'), } - if (data.device && (!phone.isOpen || data.device.imei !== phone.device?.imei)) { + if ( + data.device && + (!phone.isOpen || data.device.imei !== phone.device?.imei) + ) { notification.device = { imei: data.device.imei, name: data.device.name, @@ -226,7 +260,10 @@ function onMessage(event: MessageEvent): void { }), title: data.title ?? phone.t('Apps.calendar.name'), } - if (data.device && (!phone.isOpen || data.device.imei !== phone.device?.imei)) { + if ( + data.device && + (!phone.isOpen || data.device.imei !== phone.device?.imei) + ) { notification.device = { imei: data.device.imei, name: data.device.name, @@ -318,44 +355,7 @@ onMounted(() => { } }, 1000) if (isDevelopment) { - hydratePhone({ - account: { - devices: [ - { - created_at: '2026-08-04 12:00:00', - current: true, - device_name: 'iFruit Phone', - imei: '356938035643809', - updated_at: '2026-08-06 12:00:00', - }, - ], - email: 'demo@ifruit.com', - id: 1, - }, - device: { - data: {}, - imei: '356938035643809', - name: 'iFruit Phone', - sim: { - id: 'development-sim', - number: '5551234567', - registered: true, - type: 'registered', - }, - }, - notes: [ - { - body: 'Meet Morgan in Vinewood and inspect the Sultan RS.', - createdAt: Date.now() - 3_600_000, - id: 'demo-note-marketplace', - pinned: true, - revision: 1, - title: 'CityMarkt viewing', - updatedAt: Date.now() - 3_600_000, - }, - ], - token: 'development', - }) + void hydrateDevelopmentPhone() if (new URLSearchParams(window.location.search).has('simPickerPreview')) { simPicker.value = { choices: [ @@ -475,7 +475,11 @@ onBeforeUnmount(() => { - + diff --git a/frontend/src/components/SpringboardWidgets.vue b/frontend/src/components/SpringboardWidgets.vue index 1969b8b..dace07e 100644 --- a/frontend/src/components/SpringboardWidgets.vue +++ b/frontend/src/components/SpringboardWidgets.vue @@ -16,11 +16,15 @@ import { import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue' import { useRouter } from 'vue-router' +import { useAccountStore } from '@/stores/account' +import { useCalendarStore } from '@/stores/calendar' import { usePhoneStore } from '@/stores/phone' import { useWeatherStore } from '@/stores/weather' import type { WeatherConditionId } from '@/types/weather' const phone = usePhoneStore() +const account = useAccountStore() +const calendar = useCalendarStore() const weather = useWeatherStore() const router = useRouter() const now = ref(new Date()) @@ -57,15 +61,46 @@ const date = computed(() => }).format(now.value), ) const day = computed(() => now.value.getDate()) +const nextCalendarEvent = computed(() => + calendar.events + .filter((event) => event.endsAt >= now.value.getTime()) + .sort((left, right) => left.startsAt - right.startsAt) + .at(0), +) +const calendarEventLabel = computed( + () => + nextCalendarEvent.value?.title ?? phone.t('Home.widgets.calendar.event'), +) + +async function loadCalendarDay(): Promise { + if (!account.email) { + calendar.events = [] + return + } + + const start = new Date(now.value) + start.setHours(0, 0, 0, 0) + const end = new Date(start) + end.setDate(end.getDate() + 1) + await calendar.load(start.getTime(), end.getTime()) +} function openWeather(): void { phone.setLaunchOrigin(null) void router.push('/apps/weather') } +function openCalendar(): void { + phone.setLaunchOrigin(null) + void router.push('/apps/calendar') +} + onMounted(() => { + void loadCalendarDay() intervalId = window.setInterval(() => { + const previousDay = now.value.toDateString() now.value = new Date() + if (now.value.toDateString() !== previousDay) void loadCalendarDay() }, 60_000) }) @@ -91,11 +126,16 @@ onBeforeUnmount(() => {
-
+
+ {{ calendarEventLabel }} +