mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
1216 lines
50 KiB
TypeScript
1216 lines
50 KiB
TypeScript
|
||
import { defineStore } from 'pinia'
|
||
|
||
import type { AppLaunchOrigin, LaunchablePhoneAppId } from '@/types/apps'
|
||
import type { DeviceBootstrap, PhoneDevice } from '@/types/device'
|
||
import { clampPage } from '@/utils/pages'
|
||
import { cloneJsonData } from '@/utils/clone'
|
||
import { nuiCall } from '@/utils/nui'
|
||
import {
|
||
DEFAULT_PHONE_PREFERENCES,
|
||
parsePhonePreferences,
|
||
type AppNotificationPreferences,
|
||
type PhonePreferencesV1,
|
||
type WallpaperId,
|
||
} from '@/utils/preferences'
|
||
|
||
type LocaleTree = Record<string, unknown>
|
||
|
||
export type PhoneOpenPayload = {
|
||
account?: DeviceBootstrap['account']
|
||
device?: PhoneDevice
|
||
lang?: string
|
||
locales?: LocaleTree
|
||
notes?: DeviceBootstrap['notes']
|
||
token?: string
|
||
}
|
||
|
||
const namespaceQueues = new Map<string, Promise<void>>()
|
||
|
||
const defaultLocales: LocaleTree = {
|
||
Apps: {
|
||
darkchat: {
|
||
name: 'DarkChat', newMessage: 'New DarkChat message from {sender}', privateNotification: 'New DarkChat message',
|
||
signInBody: 'DarkChat identities are linked to your private iFruit account.', signInHint: 'Sign in through Settings to continue.',
|
||
security: 'Security', privateNetwork: 'Private invitation-only network', noResults: 'No Results', noResultsBody: 'Try another alias or Dark-ID.',
|
||
noChats: 'No Private Chats', noChatsBody: 'Connect with a Dark-ID or invitation code. There is no public search.', newChat: 'New Chat',
|
||
connectPrivately: 'Connect privately', newChatBody: 'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.', darkIdOrInvite: 'Dark-ID or invitation code', continue: 'Continue', contacts: 'DarkChat Contacts', shareIdentity: 'Tap to share your private identity',
|
||
message: 'Dark message', activeNow: 'Activity shared', encryptedSession: 'Private session', serverPrivate: 'Private server-stored conversation',
|
||
emoji: 'Emoji', gif: 'GIF', gifs: 'GIFs', photo: 'Photo', video: 'Video', attachPhoto: 'Attach Photo', takePhoto: 'Take Photo', attachGif: 'Attach GIF', attachVideo: 'Attach Video', searchGifs: 'Search GIFs', loadMore: 'Load More',
|
||
voiceMessage: 'Voice message', sending: 'Sending', failed: 'Not delivered', delivered: 'Delivered', read: 'Read', replying: 'Replying to',
|
||
messageDeleted: 'Message deleted', securityUpdate: 'Security settings updated', timerChanged: 'Disappearing messages: {timer}',
|
||
timerOff: 'Off', timerAfterRead: 'After reading', timerMinute: '1 minute', timerFiveMinutes: '5 minutes', timerHour: '1 hour', timerDay: '24 hours', timerWeek: '7 days',
|
||
copied: 'Copied', reply: 'Reply', copy: 'Copy', deleteForMe: 'Delete for me', deleteForBoth: 'Delete for both', report: 'Report',
|
||
contactSecurity: 'Contact & Security', chatSince: 'Private chat since {date}', notifications: 'Notifications', readReceipts: 'Read receipts', disappearing: 'Disappearing messages', contactAlias: 'Contact alias', saveContact: 'Save Contact', addContact: 'Add Contact', contactSaved: 'Contact saved', removeContact: 'Remove Contact', block: 'Block User', unblock: 'Unblock User', clearChat: 'Clear Chat', chatCleared: 'Chat cleared',
|
||
myIdentity: 'My Dark Identity', alias: 'Alias', notificationPrivacy: 'Notification privacy', notificationFull: 'Full · alias and message', notificationPrivate: 'Private · generic message', notificationHidden: 'Invisible · badge only', shareActivity: 'Share activity status', inviteCode: 'Private invitation code', copyInvite: 'Copy Invite', privacyDisclaimer: 'DarkChat stores messages on the server and does not claim end-to-end encryption.',
|
||
unknownIdentity: 'Unknown Identity', unknownIdentityBody: 'Only continue if you expected this identity. The account is not discoverable through public search.', openSecureChat: 'Open Private Chat',
|
||
reportUser: 'Report User', reportSpam: 'Spam', reportHarassment: 'Harassment', reportThreats: 'Threats', reportIllegal: 'Illegal content', reportOther: 'Other', reportDetails: 'Optional details', submitReport: 'Submit Report', reported: 'Report submitted',
|
||
microphoneUnavailable: 'The microphone is unavailable.', recordingTooLarge: 'The voice message is too large.',
|
||
errors: {
|
||
not_authenticated: 'Sign in to your iFruit account first.', invalid_dark_id: 'Enter a valid Dark-ID or invitation code.', profile_not_found: 'This private identity was not found.', self_chat: 'You cannot message your own identity.', conversation_not_found: 'This conversation is unavailable.', blocked: 'Messages are blocked in this conversation.', invalid_message: 'Enter a valid message.', invalid_gif: 'This GIF is invalid.', invalid_voice: 'This voice message is invalid.', invalid_attachment: 'This photo or video is unavailable.', invalid_profile: 'Check your alias and privacy settings.', rate_limited: 'Too many requests. Try again shortly.', gif_provider_unconfigured: 'GIF search is not configured.', gif_provider_unauthorized: 'The GIF provider key is invalid.', gif_provider_rate_limited: 'GIF search is busy. Try again shortly.', gif_provider_failed: 'GIFs are temporarily unavailable.', default: 'DarkChat could not complete the request.',
|
||
},
|
||
},
|
||
messages: {
|
||
name: 'Messages',
|
||
newMessage: 'New message from {sender}',
|
||
compose: 'New Message',
|
||
search: 'Search',
|
||
to: 'To:',
|
||
message: 'Message',
|
||
send: 'Send',
|
||
details: 'Details',
|
||
filterUnread: 'Show Unread Messages',
|
||
smsLabel: 'Text Message · SMS',
|
||
photo: 'Photo',
|
||
gif: 'GIF',
|
||
video: 'Video',
|
||
attachPhoto: 'Attach Photo',
|
||
takePhoto: 'Take Photo',
|
||
attachGif: 'Attach GIF',
|
||
attachVideo: 'Attach Video',
|
||
photos: 'Photos',
|
||
gifs: 'GIFs',
|
||
videos: 'Videos',
|
||
noPhotos: 'Take a photo first to attach it here.',
|
||
noVideos: 'Record a video in Camera first.',
|
||
searchGifs: 'Search GIPHY',
|
||
loadMore: 'Load More',
|
||
retryGifs: 'Try Again',
|
||
moreActions: 'More Actions',
|
||
contactDetails: 'Contact Details',
|
||
contactName: 'Name',
|
||
phoneNumber: 'Phone Number',
|
||
call: 'Call',
|
||
messageAction: 'Message',
|
||
addContact: 'Add Contact',
|
||
deleteContact: 'Delete Contact',
|
||
selectedCount: '{count} Selected',
|
||
deleteSelected: 'Delete',
|
||
contactSaveFailed: 'The contact could not be saved.',
|
||
contactDeleteFailed: 'The contact could not be deleted.',
|
||
callFailed: 'The call could not be started.',
|
||
emoji: 'Emoji',
|
||
voiceMessage: 'Audio Message',
|
||
recordVoice: 'Record Audio',
|
||
playAudio: 'Play Audio',
|
||
pauseAudio: 'Pause Audio',
|
||
recording: 'Recording',
|
||
stopAndSend: 'Stop and Send',
|
||
cancelRecording: 'Cancel Recording',
|
||
sending: 'Sending...',
|
||
delivered: 'Delivered',
|
||
notDelivered: 'Not Delivered',
|
||
microphoneUnavailable: 'The microphone is unavailable.',
|
||
recordingTooLarge: 'The recording is too large.',
|
||
noSim: 'No SIM',
|
||
noSimBody: 'Insert a SIM card in Settings to send and receive messages.',
|
||
noMessages: 'No Messages',
|
||
noMessagesBody: 'Start a conversation with a phone number.',
|
||
noResults: 'No Results',
|
||
today: 'Today',
|
||
yesterday: 'Yesterday',
|
||
errors: {
|
||
invalid_number: 'Enter a valid phone number.',
|
||
invalid_message: 'Enter a message.',
|
||
invalid_voice: 'The audio message is invalid.',
|
||
invalid_attachment: 'The attachment is invalid.',
|
||
media_provider_unconfigured: 'Photo uploads are not configured.',
|
||
capture_provider_unavailable: 'The screenshot resource is unavailable.',
|
||
capture_failed: 'The photo could not be captured.',
|
||
video_provider_unavailable: 'Video capture requires the screencapture resource.',
|
||
video_capture_failed: 'The video could not be recorded.',
|
||
recording_in_progress: 'A video is already being recorded.',
|
||
recording_not_found: 'No active video recording was found.',
|
||
gif_provider_unconfigured: 'GIF search is not configured.',
|
||
gif_provider_unauthorized: 'The configured GIPHY API key is invalid.',
|
||
gif_provider_rate_limited: 'GIPHY is busy. Try again in a moment.',
|
||
gif_provider_failed: 'GIF search is temporarily unavailable.',
|
||
self_message: 'You cannot message your own number.',
|
||
recipient_not_found: 'That number is unavailable.',
|
||
no_sim: 'This phone has no SIM card.',
|
||
rate_limited: 'Too many messages. Try again in a minute.',
|
||
request_failed: 'Messages are temporarily unavailable.',
|
||
default: 'The message could not be sent.',
|
||
},
|
||
},
|
||
appStore: {
|
||
name: 'App Store',
|
||
get: 'GET',
|
||
open: 'OPEN',
|
||
installing: 'Installing',
|
||
searchPlaceholder: 'Search apps and games',
|
||
appsTitle: 'Built-in Apps',
|
||
gamesTitle: 'Games',
|
||
selected: 'Apps available for your Sky Phone',
|
||
tabs: {
|
||
apps: 'Apps',
|
||
games: 'Games',
|
||
search: 'Search',
|
||
},
|
||
},
|
||
phone: {
|
||
name: 'Phone',
|
||
recents: 'Recents',
|
||
contacts: 'Contacts',
|
||
keypad: 'Keypad',
|
||
noSim: 'No SIM',
|
||
noSimBody: 'Insert a SIM card in Settings to make calls.',
|
||
noRecents: 'No Recent Calls',
|
||
noContacts: 'No Contacts',
|
||
searchContacts: 'Search Contacts',
|
||
addContact: 'New Contact',
|
||
editContact: 'Edit Contact',
|
||
contactName: 'Name',
|
||
phoneNumber: 'Phone Number',
|
||
call: 'Call',
|
||
calling: 'calling...',
|
||
incoming: 'Incoming Call',
|
||
incomingDirection: 'Incoming',
|
||
outgoingDirection: 'Outgoing',
|
||
connected: 'Connected',
|
||
missed: 'Missed',
|
||
declined: 'Declined',
|
||
busy: 'Busy',
|
||
unavailable: 'Unavailable',
|
||
noAnswer: 'No Answer',
|
||
cancelled: 'Cancelled',
|
||
disconnected: 'Disconnected',
|
||
sim_removed: 'SIM Removed',
|
||
completed: 'Call Ended',
|
||
answer: 'Answer',
|
||
decline: 'Decline',
|
||
hangup: 'End',
|
||
addToContacts: 'Add to Contacts',
|
||
deleteContact: 'Delete Contact',
|
||
replaceTitle: 'Replace SIM?',
|
||
replaceBody: 'The current SIM will be returned to your inventory.',
|
||
choosePhone: 'Choose a Phone',
|
||
choosePhoneBody: 'Select the phone that should receive {number}.',
|
||
emptyPhone: 'No SIM inserted',
|
||
errors: {
|
||
invalid_contact: 'Enter a name and valid phone number.',
|
||
invalid_number: 'Enter a valid phone number.',
|
||
no_sim: 'This phone has no SIM card.',
|
||
airplane_mode: 'Turn off Airplane Mode to make calls.',
|
||
self_call: 'You cannot call your own number.',
|
||
busy: 'The line is busy.',
|
||
rate_limited: 'Too many calls. Try again in a minute.',
|
||
voice_unavailable: 'The configured phone voice service is unavailable.',
|
||
inventory_full: 'There is no room for the ejected SIM card.',
|
||
operation_in_progress:
|
||
'Another phone operation is already in progress.',
|
||
sim_request_expired:
|
||
'The SIM selection expired. Use the SIM card again.',
|
||
sim_not_owned: 'That SIM card is no longer in your inventory.',
|
||
phone_not_owned: 'That phone is no longer in your inventory.',
|
||
metadata_unsupported:
|
||
'The configured inventory cannot store unique SIM metadata.',
|
||
request_failed: 'The phone request failed.',
|
||
default: 'The phone request failed.',
|
||
},
|
||
},
|
||
banking: {
|
||
name: 'Banking',
|
||
welcome: 'Welcome back',
|
||
totalBalance: 'Total Balance',
|
||
recentPeriod: 'in recent activity',
|
||
actions: 'Banking actions',
|
||
send: 'Send',
|
||
accounts: 'Accounts',
|
||
bankAccount: 'Bank account',
|
||
cash: 'Cash',
|
||
latestTransactions: 'Latest Transactions',
|
||
allTransactions: 'All Transactions',
|
||
viewAll: 'View all',
|
||
noTransactions: 'Your banking activity will appear here.',
|
||
home: 'Home',
|
||
activity: 'Activity',
|
||
chartDaySummary: '{day}: {incoming} incoming, {outgoing} outgoing',
|
||
navigation: 'Banking navigation',
|
||
incoming: 'Incoming',
|
||
outgoing: 'Outgoing',
|
||
refresh: 'Refresh banking data',
|
||
unavailable: 'Banking unavailable',
|
||
tryAgain: 'Try Again',
|
||
playerId: 'Player ID',
|
||
playerIdPlaceholder: 'Enter the recipient ID',
|
||
amount: 'Amount',
|
||
amountPlaceholder: 'Enter an amount',
|
||
transactions: {
|
||
deposit: 'Cash deposit',
|
||
withdrawal: 'Cash withdrawal',
|
||
transfer_in: 'Incoming transfer',
|
||
transfer_out: 'Outgoing transfer',
|
||
},
|
||
forms: {
|
||
transfer: {
|
||
title: 'Send money',
|
||
body: 'Transfer money from your bank account to an online player.',
|
||
submit: 'Send transfer',
|
||
},
|
||
},
|
||
errors: {
|
||
invalid_request: 'Enter a valid whole amount and player ID.',
|
||
insufficient_funds: 'There is not enough money in this account.',
|
||
target_not_found: 'The recipient is not online.',
|
||
self_transfer: 'You cannot send money to yourself.',
|
||
rate_limited: 'Please wait before making another transaction.',
|
||
transfer_failed: 'The transfer could not be completed.',
|
||
banking_unavailable: 'Banking is currently unavailable.',
|
||
request_failed: 'The banking request failed.',
|
||
default: 'The banking request failed.',
|
||
},
|
||
},
|
||
calculator: { name: 'Calculator' },
|
||
snake: {
|
||
name: 'Snake',
|
||
backToMenu: 'Back to game menu',
|
||
board: 'Snake game board',
|
||
controls: 'Direction controls',
|
||
directions: {
|
||
down: 'Move down',
|
||
left: 'Move left',
|
||
right: 'Move right',
|
||
up: 'Move up',
|
||
},
|
||
gameOver: 'Game Over',
|
||
highScore: 'High Score',
|
||
menu: 'Main Menu',
|
||
pause: 'Pause game',
|
||
paused: 'Paused',
|
||
readyBody: 'Collect fruit, grow longer, and stay clear of every wall.',
|
||
readyTitle: 'Ready to play?',
|
||
restart: 'Play Again',
|
||
resume: 'Resume game',
|
||
score: 'Score',
|
||
speed: 'Speed',
|
||
speeds: { fast: 'Fast', normal: 'Normal', relaxed: 'Relaxed' },
|
||
start: 'Start Game',
|
||
swipeHint: 'Use the arrow keys to steer',
|
||
},
|
||
memory: {
|
||
name: 'Memory',
|
||
backToMenu: 'Back to board selection',
|
||
board: 'Memory card board',
|
||
chooseBody: 'Find every matching pair with as few moves as possible.',
|
||
chooseTitle: 'Choose a board',
|
||
completeEyebrow: 'Board cleared',
|
||
completeTitle: 'Great memory!',
|
||
difficulties: { large: 'Expert', medium: 'Classic', small: 'Quick' },
|
||
eyebrow: 'Matching game',
|
||
hiddenCard: 'Hidden card',
|
||
menu: 'Board Selection',
|
||
moves: 'Moves',
|
||
mute: 'Mute game sounds',
|
||
noBest: 'No best score yet',
|
||
playAgain: 'Play Again',
|
||
restart: 'Restart',
|
||
revealedCard: 'Revealed card',
|
||
time: 'Time',
|
||
unmute: 'Turn on game sounds',
|
||
},
|
||
numberMerge: {
|
||
name: '2048',
|
||
backToMenu: 'Back to game menu',
|
||
best: 'Best',
|
||
board: '2048 game board',
|
||
cancel: 'Keep Current Game',
|
||
confirmBody: 'Your current board will be replaced. This cannot be undone.',
|
||
confirmNew: 'Start New Game',
|
||
confirmTitle: 'Replace current game?',
|
||
continueGame: 'Continue Game',
|
||
controls: 'Move controls',
|
||
directions: {
|
||
down: 'Move tiles down',
|
||
left: 'Move tiles left',
|
||
right: 'Move tiles right',
|
||
up: 'Move tiles up',
|
||
},
|
||
eyebrow: 'Number puzzle',
|
||
gameOverBody: 'No empty spaces or matching neighbours remain.',
|
||
gameOverTitle: 'No more moves',
|
||
highest: 'Highest Tile',
|
||
howToBody: 'Swipe to combine matching tiles. Each pair becomes one larger tile.',
|
||
howToExample: '2 + 2 = 4 · 4 + 4 = 8 · … · 1024 + 1024 = 2048',
|
||
howToGoal: 'Goal: create the 2048 tile before the board has no moves left.',
|
||
howToTitle: 'How to play',
|
||
keepPlaying: 'Keep Playing',
|
||
mainMenu: 'Main Menu',
|
||
menuBody: 'Slide matching numbers together and work your way up to 2048.',
|
||
menuTitle: 'Build the 2048 tile',
|
||
mute: 'Mute game sounds',
|
||
newGame: 'New Game',
|
||
score: 'Score',
|
||
swipeHint: 'Swipe the board, use the buttons, or press arrow keys / WASD',
|
||
unmute: 'Turn on game sounds',
|
||
wonBody: 'You reached the legendary tile. Continue climbing or begin again.',
|
||
wonTitle: 'You made 2048!',
|
||
},
|
||
minesweeper: {
|
||
name: 'Minesweeper',
|
||
backToMenu: 'Back to game menu',
|
||
board: 'Minesweeper board',
|
||
chooseBody:
|
||
'Reveal every safe field. Numbers show how many mines touch that field.',
|
||
chooseTitle: 'Choose a minefield',
|
||
difficulties: { classic: 'Classic', expert: 'Expert', quick: 'Quick' },
|
||
emptyCell: 'Revealed empty field',
|
||
eyebrow: 'Mine puzzle',
|
||
flaggedCell: 'Flagged field',
|
||
gameHint: 'Tap to reveal · Hold or right-click to flag',
|
||
hiddenCell: 'Hidden field',
|
||
longPressHint: 'Tap to reveal · Hold to place a flag',
|
||
lostBody:
|
||
'A mine was hidden there. Mark suspicious fields and try again.',
|
||
lostTitle: 'Mine triggered',
|
||
mainMenu: 'Main Menu',
|
||
mineCell: 'Revealed mine',
|
||
mines: 'Mines',
|
||
mute: 'Mute game sounds',
|
||
noBest: 'No best time yet',
|
||
numberCell: 'Revealed field with {count} adjacent mines',
|
||
playAgain: 'Play Again',
|
||
restart: 'Restart',
|
||
resume: 'Continue Game',
|
||
time: 'Time',
|
||
unmute: 'Turn on game sounds',
|
||
wonBody: 'Minefield cleared in {time}.',
|
||
wonTitle: 'Field cleared!',
|
||
},
|
||
towerStack: {
|
||
name: 'Tower Stack',
|
||
backToMenu: 'Back to game menu',
|
||
bestHeight: 'Best Height',
|
||
bestScore: 'Best Score',
|
||
blocks: 'Blocks',
|
||
eyebrow: 'Timing challenge',
|
||
finalScore: 'Final Score',
|
||
gameHint: 'Tap the playfield to place the moving block',
|
||
gameOver: 'Tower collapsed',
|
||
height: 'Height',
|
||
mainMenu: 'Main Menu',
|
||
menuBody:
|
||
'Stop each moving block above the tower. Only the overlapping part remains.',
|
||
menuTitle: 'How high can you build?',
|
||
mute: 'Mute game sounds',
|
||
newGame: 'Start New Game',
|
||
pause: 'Pause or resume game',
|
||
paused: 'Paused',
|
||
perfect: 'PERFECT!',
|
||
placeBlock: 'Place moving block',
|
||
playAgain: 'Play Again',
|
||
ready: 'Ready to stack?',
|
||
resume: 'Continue Game',
|
||
score: 'Score',
|
||
start: 'Start Game',
|
||
tapHint: 'Every tap places one block · perfect hits earn bonus points',
|
||
unmute: 'Turn on game sounds',
|
||
},
|
||
skyFlappy: {
|
||
name: 'Sky Flappy',
|
||
backToMenu: 'Back to game menu',
|
||
best: 'Best',
|
||
design: 'Sky design',
|
||
designs: { dawn: 'Dawn', neon: 'Neon', storm: 'Storm' },
|
||
eyebrow: 'Sky challenge',
|
||
firstTap: 'Tap to launch',
|
||
flap: 'Fly upward',
|
||
gameHint: 'Tap anywhere in the sky to fly upward',
|
||
gameOver: 'Flight ended',
|
||
highScore: 'High Score',
|
||
mainMenu: 'Main Menu',
|
||
menuBody:
|
||
'Guide the sky bird through every tower opening without touching the edges.',
|
||
menuTitle: 'Fly between the towers',
|
||
mute: 'Mute game sounds',
|
||
pause: 'Pause or resume game',
|
||
paused: 'Paused',
|
||
playAgain: 'Fly Again',
|
||
points: 'Points',
|
||
ready: 'Ready for takeoff?',
|
||
resume: 'Continue Flight',
|
||
score: 'Score',
|
||
start: 'Start Flight',
|
||
tapHint: 'Tap to flap · gravity pulls the bird down',
|
||
unmute: 'Turn on game sounds',
|
||
},
|
||
neonDrop: {
|
||
name: 'Neon Drop',
|
||
backToMenu: 'Back to game menu',
|
||
bestLines: 'Best Lines',
|
||
bestScore: 'Best Score',
|
||
board: 'Neon Drop reactor board',
|
||
controls: 'Block controls',
|
||
eyebrow: 'Energy block puzzle',
|
||
gameHint: 'Swipe the reactor or use the five controls',
|
||
gameOver: 'Reactor overloaded',
|
||
hardDrop: 'Drop block immediately',
|
||
howToBody:
|
||
'Fill complete horizontal rows to dissolve them and keep the reactor clear.',
|
||
howToTitle: 'Charge the reactor',
|
||
left: 'Move block left',
|
||
level: 'Level',
|
||
lines: 'Lines',
|
||
mainMenu: 'Main Menu',
|
||
menuBody:
|
||
'Rotate and place falling energy shapes. Every cleared row increases your charge.',
|
||
menuTitle: 'Build complete energy lines',
|
||
mute: 'Mute game sounds',
|
||
newGame: 'Start New Game',
|
||
next: 'Next',
|
||
pause: 'Pause or resume game',
|
||
paused: 'Paused',
|
||
playAgain: 'Play Again',
|
||
points: 'Points',
|
||
ready: 'Reactor ready',
|
||
resume: 'Continue Game',
|
||
right: 'Move block right',
|
||
rotate: 'Rotate block',
|
||
score: 'Score',
|
||
softDrop: 'Move block down',
|
||
start: 'Start Reactor',
|
||
unmute: 'Turn on game sounds',
|
||
},
|
||
citymarkt: {
|
||
name: 'CityMarkt',
|
||
eyebrow: 'Los Santos marketplace',
|
||
tabs: { discover: 'Discover', search: 'Search', sell: 'Sell', inbox: 'Inbox', profile: 'Me' },
|
||
categories: {
|
||
vehicles: 'Vehicles', property: 'Property', electronics: 'Electronics', clothing: 'Clothing',
|
||
tools: 'Tools', leisure: 'Leisure', services: 'Services', jobs: 'Jobs', wanted: 'Wanted', other: 'Other',
|
||
},
|
||
conditions: { new: 'New', very_good: 'Very good', used: 'Used', defective: 'Defective' },
|
||
priceTypes: { fixed: 'Fixed price', negotiable: 'Negotiable', free: 'Free' },
|
||
offerStatus: {
|
||
pending: 'Open',
|
||
accepted: 'Accepted',
|
||
declined: 'Declined',
|
||
countered: 'Negotiated',
|
||
},
|
||
districts: {
|
||
los_santos: 'Los Santos', vinewood: 'Vinewood', vespucci: 'Vespucci',
|
||
south_los_santos: 'South Los Santos', sandy_shores: 'Sandy Shores',
|
||
paleto_bay: 'Paleto Bay', blaine_county: 'Blaine County',
|
||
},
|
||
status: { active: 'Active', reserved: 'Reserved', sold: 'Sold', expired: 'Expired', removed: 'Removed' },
|
||
reportReasons: { prohibited: 'Prohibited item', fraud: 'Suspected fraud', spam: 'Spam', offensive: 'Offensive content', other: 'Other' },
|
||
searchPlaceholder: 'What are you looking for?', allCategories: 'All categories', allDistricts: 'All districts',
|
||
sortNewest: 'Newest first', sortPriceAsc: 'Lowest price', sortPriceDesc: 'Highest price',
|
||
freshOffers: 'Fresh offers', offers: 'offers', noListings: 'No offers found',
|
||
noListingsBody: 'Try another search or category.', noDistrict: 'No district',
|
||
free: 'Free', negotiablePrice: '${price} negotiable', money: '${price}',
|
||
hoursAgo: '{count}h ago', daysAgo: '{count}d ago', photos: 'photos', activeListings: 'active listings',
|
||
signInTitle: 'Sign in to iFruit', signInBody: 'Use Settings to sign in before selling, saving or messaging.',
|
||
noMessages: 'No conversations', noMessagesBody: 'Messages about offers will appear here.',
|
||
myListings: 'My listings', favorites: 'Favorites', noProfileListings: 'Nothing here yet',
|
||
phone: 'Phone', contactSeller: 'Contact seller', messagePlaceholder: 'Hi, is this still available?',
|
||
signInToMessage: 'Sign in to your iFruit account to send a message.',
|
||
edit: 'Edit', makeActive: 'Make active', markSold: 'Mark sold', remove: 'Remove',
|
||
createListing: 'Create listing', editListing: 'Edit listing', save: 'Save', step: 'Step {current} of {total}', next: 'Next', previous: 'Back', publish: 'Publish',
|
||
addPhotos: 'Add photos', addPhotosBody: 'Choose up to six photos from your gallery or take new ones. Photos are optional and the first becomes the cover.',
|
||
chooseGallery: 'Choose from gallery', chooseGalleryBody: 'Use photos saved on this phone.',
|
||
takePhotos: 'Take photos', takePhotosBody: 'Take several new photos for this listing.',
|
||
selectedPhotos: 'Selected photos', gallery: 'Gallery', camera: 'Camera', takePhoto: 'Take photo',
|
||
cameraHint: 'Take as many photos as you need. Each shot is added to the listing automatically.',
|
||
noPhoto: 'No photo available', noPhotoBody: 'The seller did not add a photo to this listing.',
|
||
noPhotoOptional: 'You can publish without a photo or add one from the gallery or camera.',
|
||
previousPhoto: 'Previous photo', nextPhoto: 'Next photo', photo: 'Photo',
|
||
removePhoto: 'Remove photo {number}', photoLimit: 'You can add up to six photos.',
|
||
describeOffer: 'Describe your offer', title: 'Title', description: 'Description', category: 'Category', condition: 'Condition',
|
||
characterCount: '{current} / {maximum} characters', minimumCharacters: 'min. {minimum}',
|
||
priceAndPlace: 'Price and location', priceType: 'Price type', price: 'Price', district: 'District',
|
||
showPhone: 'Show my current phone number', preview: 'Preview', published: 'Your listing is live.',
|
||
writeMessage: 'Write a message', reserveForBuyer: 'Reserve for this buyer', statusChanged: 'Listing updated.',
|
||
offer: 'Offer', counterOffer: 'Counteroffer', makeOffer: 'Make an offer',
|
||
offeredByYou: 'You sent this offer.', offeredToYou: 'You received this offer.',
|
||
acceptOffer: 'Accept', declineOffer: 'Decline', negotiateOffer: 'Counter',
|
||
offerFor: 'Your offer for', offerAmount: 'Offer amount', sendOffer: 'Send offer',
|
||
offerSent: 'Your offer was sent.', offerAccepted: 'Offer accepted.', offerDeclined: 'Offer declined.',
|
||
reportListing: 'Report listing', reportWhy: 'Why are you reporting this?', reportDetails: 'Add details (optional)',
|
||
sendReport: 'Send report', blockSeller: 'Block seller', reported: 'Report sent.', blocked: 'Seller blocked.',
|
||
newMessage: 'New CityMarkt message from {sender}',
|
||
newOffer: '{sender} offered ${price}.',
|
||
offerAcceptedNotification: '{sender} accepted your ${price} offer.',
|
||
offerRejectedNotification: '{sender} declined your ${price} offer.',
|
||
errors: {
|
||
invalid_listing: 'Check the listing details.', invalid_price: 'Enter a valid price.',
|
||
invalid_images: 'Choose valid photos from this phone.', phone_unavailable: 'Insert a SIM or hide your number.',
|
||
listing_limit: 'You have reached the active listing limit.', listing_not_found: 'This listing is no longer available.',
|
||
invalid_message: 'Enter a valid message.', inquiry_not_found: 'This conversation is no longer available.',
|
||
invalid_offer: 'Enter a valid whole-number offer.', invalid_offer_response: 'This offer action is invalid.',
|
||
offer_listing_unavailable: 'This listing is no longer available for offers.',
|
||
offer_closed: 'This negotiation is already complete.', offer_waiting: 'Wait for the other person to respond.',
|
||
offer_not_allowed: 'You cannot make an offer right now.', offer_not_actionable: 'This offer can no longer be changed.',
|
||
offer_conflict: 'The offer changed. Please try again.',
|
||
blocked: 'Messages are blocked between these accounts.', already_reported: 'You already reported this listing.',
|
||
rate_limited: 'Too many requests. Try again shortly.', not_authenticated: 'Sign in to your iFruit account first.',
|
||
conflict: 'The listing changed. Open it again.', request_failed: 'CityMarkt is temporarily unavailable.',
|
||
default: 'The CityMarkt request failed.',
|
||
},
|
||
},
|
||
localPages: {
|
||
name: 'Local Pages', eyebrow: 'Your city. Your stories.', cityPulse: 'Live from Los Santos',
|
||
heroTitle: 'What is happening nearby?', heroBody: 'Discover places, people and local tips.',
|
||
searchPlaceholder: 'Search posts and places', search: 'Search', allCategories: 'All categories',
|
||
allLosSantos: 'Los Santos', hoursAgo: '{count}h ago', daysAgo: '{count}d ago',
|
||
categories: { recommendation: 'Recommended', wanted: 'Wanted', service: 'Service', event: 'Event', place: 'Place', community: 'Community', citymarkt: 'CityMarkt' },
|
||
discover: 'Discover', create: 'Post', profile: 'Profile', post: 'Post', posts: 'posts',
|
||
noPosts: 'Nothing here yet', noPostsBody: 'Be the first to share something with the city.', noPhoto: 'No photo attached',
|
||
signInTitle: 'Your Local Pages profile', signInBody: 'Sign in to iFruit in Settings to publish and save posts.',
|
||
localCreator: 'Local creator', myPosts: 'My posts', saved: 'Saved', save: 'Save', likes: 'likes',
|
||
location: 'Location', sharedFrom: 'Shared from CityMarkt', openCityMarkt: 'Open CityMarkt listing',
|
||
newPost: 'New local post', shareWithCity: 'Share with the city', publish: 'Publish', published: 'Your post is live.', deleted: 'Post deleted.',
|
||
title: 'Title', body: 'Your story', category: 'Category', titlePlaceholder: 'What should people know?', bodyPlaceholder: 'Add details, a recommendation or directions...',
|
||
photos: 'Photos', optional: 'optional', camera: 'Camera', gallery: 'Gallery', photoLimit: 'You can add up to six photos.',
|
||
cityMarktShare: 'Share to Local Pages', cityMarktShared: 'Shared to Local Pages.', cityMarktShareHint: 'One CityMarkt share per day',
|
||
errors: { invalid_post: 'Add a title and a little more detail.', invalid_images: 'Choose valid photos from this phone.', invalid_request: 'This action is not valid.', post_not_found: 'This post is no longer available.', citymarkt_not_found: 'This CityMarkt listing is unavailable.', citymarkt_daily_limit: 'You already shared a CityMarkt listing today.', citymarkt_already_shared: 'This listing was already shared.', not_authenticated: 'Sign in to iFruit first.', rate_limited: 'Too many requests. Try again shortly.', request_failed: 'The post could not be saved.', default: 'Local Pages is temporarily unavailable.' },
|
||
},
|
||
map: {
|
||
name: 'Map',
|
||
controls: 'Map controls',
|
||
currentLocation: 'Current Location',
|
||
imageError: 'The map image could not be loaded.',
|
||
switchStyle: 'Switch Map Type',
|
||
styles: {
|
||
default: 'Default Map',
|
||
satellite: 'Satellite Map',
|
||
atlas: 'Atlas Map',
|
||
roads: 'Road Map',
|
||
},
|
||
},
|
||
weather: {
|
||
name: 'Weather',
|
||
now: 'Now',
|
||
today: 'Today',
|
||
refresh: 'Refresh weather',
|
||
details: 'Weather details',
|
||
feelsLike: 'Feels Like',
|
||
wind: 'Wind',
|
||
humidity: 'Humidity',
|
||
rain: 'Precipitation',
|
||
hourly: '24-Hour Forecast',
|
||
daily: '7-Day Forecast',
|
||
unavailable: 'Weather is unavailable',
|
||
stale: 'Showing the last available weather update.',
|
||
tryAgain: 'Try Again',
|
||
regions: {
|
||
los_santos: 'Los Santos',
|
||
blaine_county: 'Blaine County',
|
||
cayo_perico: 'Cayo Perico',
|
||
},
|
||
conditions: {
|
||
sunny: 'Sunny',
|
||
clear: 'Clear',
|
||
partly_cloudy: 'Partly Cloudy',
|
||
cloudy: 'Cloudy',
|
||
rain: 'Rain',
|
||
thunder: 'Thunderstorms',
|
||
fog: 'Foggy',
|
||
snow: 'Snow',
|
||
},
|
||
summaries: {
|
||
sunny: 'Bright skies throughout the current period.',
|
||
clear: 'Calm and clear conditions across the region.',
|
||
partly_cloudy: 'Sunshine mixed with passing clouds.',
|
||
cloudy: 'Cloud cover will remain over the region.',
|
||
rain: 'Wet conditions are moving through the area.',
|
||
thunder: 'Stormy conditions with heavy bursts of rain.',
|
||
fog: 'Reduced visibility in low-lying areas.',
|
||
snow: 'Cold conditions with snow across the region.',
|
||
},
|
||
},
|
||
camera: {
|
||
name: 'Camera',
|
||
flash: 'Flash',
|
||
flip: 'Flip camera',
|
||
landscape: 'Switch to landscape',
|
||
portrait: 'Switch to portrait',
|
||
photo: 'Photo',
|
||
video: 'Video',
|
||
focusHelp: 'Space for movement',
|
||
returnHelp: 'Space to return',
|
||
uploading: '{count} uploading',
|
||
saving: 'Saving video...',
|
||
openGallery: 'Open Gallery',
|
||
takePhoto: 'Take photo',
|
||
startRecording: 'Start recording',
|
||
stopRecording: 'Stop recording',
|
||
saved: 'Saved to Gallery.',
|
||
zoom: 'Set camera zoom to {zoom}',
|
||
errors: {
|
||
cancelled: 'Capture cancelled.',
|
||
capture_failed: 'Unable to capture the game view.',
|
||
invalid_media_type: 'The uploaded media type is invalid.',
|
||
invalid_upload: 'The upload could not be verified.',
|
||
invalid_upload_token: 'The upload session is no longer valid.',
|
||
missing_config: 'Camera uploads are not configured.',
|
||
not_found: 'The media item no longer exists.',
|
||
operation_in_progress:
|
||
'Another media operation is already in progress.',
|
||
owner_changed: 'The active phone account changed during upload.',
|
||
rate_limited: 'Too many media actions. Try again shortly.',
|
||
request_failed: 'The camera request failed.',
|
||
request_timeout: 'The media service timed out.',
|
||
unsupported: 'Video recording is not supported.',
|
||
upload_failed: 'The media upload failed.',
|
||
upload_timeout: 'The media upload timed out.',
|
||
},
|
||
},
|
||
calendar: {
|
||
name: 'Calendar',
|
||
today: 'Today',
|
||
calendars: 'Calendars',
|
||
calendar: 'Calendar',
|
||
calendarName: 'iFruit',
|
||
searchPlaceholder: 'Search events',
|
||
previousMonth: 'Previous month',
|
||
nextMonth: 'Next month',
|
||
eyebrow: 'Your time. Clearly planned.',
|
||
schedule: 'Schedule',
|
||
event: 'Event',
|
||
appointment: 'Appointment',
|
||
newEvent: 'New event',
|
||
editEvent: 'Edit event',
|
||
deleteEvent: 'Delete event',
|
||
details: 'Event details',
|
||
title: 'Title',
|
||
titlePlaceholder: 'What is planned?',
|
||
date: 'Date',
|
||
starts: 'Starts',
|
||
ends: 'Ends',
|
||
reminder: 'Reminder',
|
||
note: 'Note',
|
||
notePlaceholder: 'Add details, an address or anything to remember...',
|
||
noEvents: 'Nothing planned',
|
||
noEventsBody: 'This day is still free. Add an event whenever you are ready.',
|
||
signInTitle: 'Your iFruit calendar',
|
||
signInBody: 'Sign in to iFruit in Settings to sync appointments and receive reminders.',
|
||
reminderNotification: 'Upcoming: {title}',
|
||
views: {
|
||
compact: 'Compact',
|
||
stacked: 'Stacked',
|
||
details: 'Details',
|
||
list: 'List',
|
||
},
|
||
reminders: {
|
||
none: 'No reminder',
|
||
atStart: 'At start time',
|
||
tenMinutes: '10 minutes before',
|
||
thirtyMinutes: '30 minutes before',
|
||
oneHour: '1 hour before',
|
||
oneDay: '1 day before',
|
||
},
|
||
errors: {
|
||
invalid_event: 'Enter a title and make sure the end is after the start.',
|
||
invalid_range: 'This calendar period is invalid.',
|
||
conflict: 'This event changed on another phone. Open it again.',
|
||
rate_limited: 'Too many changes. Try again shortly.',
|
||
not_authenticated: 'Sign in to your iFruit account first.',
|
||
request_failed: 'Calendar is temporarily unavailable.',
|
||
default: 'The calendar request failed.',
|
||
},
|
||
},
|
||
clock: {
|
||
name: 'Clock',
|
||
lap: 'Lap',
|
||
minutes: 'Minutes',
|
||
add: 'Add alarm',
|
||
location: 'Los Santos',
|
||
tabs: {
|
||
world: 'Clock',
|
||
alarm: 'Alarm',
|
||
stopwatch: 'Stopwatch',
|
||
timer: 'Timer',
|
||
},
|
||
alarm: {
|
||
add: 'Add Alarm',
|
||
edit: 'Edit Alarm',
|
||
ringing: 'Alarm',
|
||
time: 'Time',
|
||
hours: 'Hours',
|
||
repeat: 'Repeat',
|
||
note: 'Note',
|
||
notePlaceholder: 'Alarm',
|
||
sound: 'Sound',
|
||
delete: 'Delete Alarm',
|
||
never: 'Never',
|
||
everyDay: 'Every Day',
|
||
weekdays: 'Weekdays',
|
||
weekends: 'Weekends',
|
||
days: {
|
||
sunday: 'Sunday',
|
||
monday: 'Monday',
|
||
tuesday: 'Tuesday',
|
||
wednesday: 'Wednesday',
|
||
thursday: 'Thursday',
|
||
friday: 'Friday',
|
||
saturday: 'Saturday',
|
||
},
|
||
daysShort: {
|
||
sunday: 'Sun',
|
||
monday: 'Mon',
|
||
tuesday: 'Tue',
|
||
wednesday: 'Wed',
|
||
thursday: 'Thu',
|
||
friday: 'Fri',
|
||
saturday: 'Sat',
|
||
},
|
||
sounds: {
|
||
radar: 'Radar',
|
||
beacon: 'Beacon',
|
||
chimes: 'Chimes',
|
||
apex: 'Apex',
|
||
aurora: 'Aurora',
|
||
circuit: 'Circuit',
|
||
constellation: 'Constellation',
|
||
daybreak: 'Daybreak',
|
||
uplift: 'Uplift',
|
||
},
|
||
},
|
||
timer: {
|
||
time: 'Timer duration',
|
||
hours: 'Hours',
|
||
hoursShort: 'hr',
|
||
minutes: 'Minutes',
|
||
minutesShort: 'min',
|
||
seconds: 'Seconds',
|
||
secondsShort: 'sec',
|
||
note: 'Note',
|
||
notePlaceholder: 'Timer',
|
||
sound: 'Sound',
|
||
ringing: 'Timer',
|
||
},
|
||
},
|
||
mail: {
|
||
name: 'Mail',
|
||
login: 'Sign In',
|
||
register: 'Create Account',
|
||
registerLink: 'Register',
|
||
loginTitle: 'iFruit Mail',
|
||
loginBody: 'Sign in to use your shared iFruit mailbox.',
|
||
registerBody: 'Choose your new @ifruit.com address.',
|
||
localPart: 'Email address',
|
||
email: 'Email',
|
||
password: 'Password',
|
||
confirmPassword: 'Confirm password',
|
||
accountEyebrow: 'Your iFruit account',
|
||
emailPlaceholder: 'name@ifruit.com',
|
||
passwordPlaceholder: 'Enter password',
|
||
passwordWarning:
|
||
'Use an in-character password. Do not reuse a real-world password.',
|
||
mailboxes: 'Mailboxes',
|
||
inbox: 'Inbox',
|
||
sent: 'Sent',
|
||
drafts: 'Drafts',
|
||
trash: 'Trash',
|
||
compose: 'New Message',
|
||
recipients: 'To',
|
||
recipientHint: 'Separate up to 10 addresses with commas.',
|
||
recipientPlaceholder: 'name@ifruit.com',
|
||
subject: 'Subject',
|
||
subjectPlaceholder: "What's this about?",
|
||
body: 'Message',
|
||
messagePlaceholder: 'Write a message...',
|
||
search: 'Search mail',
|
||
messages: 'messages',
|
||
noMessageContent: 'No message content',
|
||
noMail: 'No Mail',
|
||
noMailBody: 'Messages in this mailbox will appear here.',
|
||
noResults: 'No Results',
|
||
noResultsBody: 'Try a different search.',
|
||
untitled: '(no subject)',
|
||
loadMore: 'Load More',
|
||
logout: 'Sign Out',
|
||
from: 'From',
|
||
to: 'To',
|
||
reply: 'Reply',
|
||
replyAll: 'Reply All',
|
||
forward: 'Forward',
|
||
formatBold: 'Bold',
|
||
formatItalic: 'Italic',
|
||
formatBulletList: 'Bullet list',
|
||
formatNumberedList: 'Numbered list',
|
||
formatQuote: 'Quote',
|
||
undo: 'Undo',
|
||
redo: 'Redo',
|
||
markRead: 'Read',
|
||
markUnread: 'Mark as Unread',
|
||
delete: 'Delete',
|
||
moveToTrash: 'Move to Trash',
|
||
restore: 'Restore',
|
||
deleteForever: 'Delete Forever',
|
||
emptyTrash: 'Empty Trash',
|
||
emptyTrashTitle: 'Empty Trash?',
|
||
emptyTrashBody: 'Every message in Trash will be permanently deleted.',
|
||
deleteDraft: 'Delete Draft',
|
||
sentSuccess: 'Message sent.',
|
||
newMessage: 'New mail from {sender}',
|
||
passwordsMismatch: 'Passwords do not match.',
|
||
errors: {
|
||
invalid_email: 'Choose a valid 3–32 character iFruit address.',
|
||
invalid_password: 'Password must be 6–64 characters.',
|
||
invalid_credentials: 'Email or password is incorrect.',
|
||
email_taken: 'That iFruit address is already registered.',
|
||
rate_limited: 'Too many attempts. Try again in a minute.',
|
||
invalid_message: 'Add a valid recipient and a subject or message.',
|
||
recipient_not_found: 'One or more recipients do not exist.',
|
||
invalid_draft: 'This draft contains invalid content.',
|
||
not_authenticated: 'Your mail session has ended. Sign in again.',
|
||
request_failed: 'Mail is temporarily unavailable.',
|
||
invalid_request: 'The mail request was invalid.',
|
||
default: 'The mail request failed.',
|
||
},
|
||
},
|
||
notes: {
|
||
name: 'Notes',
|
||
note: 'Note',
|
||
back: 'Back',
|
||
actions: 'Note actions',
|
||
newNote: 'New Note',
|
||
searchPlaceholder: 'Search Notes',
|
||
title: 'Title',
|
||
titlePlaceholder: 'Note title',
|
||
body: 'Note',
|
||
bodyPlaceholder: 'Start writing...',
|
||
untitled: 'Untitled',
|
||
noText: 'No additional text',
|
||
emptyBadge: 'ON DEVICE',
|
||
emptyTitle: 'No Notes',
|
||
emptyBody: 'Create a note to keep important details close at hand.',
|
||
noResults: 'No Results',
|
||
noResultsBody: 'Try searching for a different word or phrase.',
|
||
pin: 'Pin note',
|
||
unpin: 'Unpin note',
|
||
deleteNote: 'Delete note',
|
||
},
|
||
photos: {
|
||
name: 'Gallery',
|
||
count: '{count} items',
|
||
loading: 'Loading Gallery...',
|
||
emptyTitle: 'No Photos or Videos',
|
||
emptyBody: 'Captures from Camera will appear here.',
|
||
photo: 'Photo',
|
||
video: 'Video',
|
||
photoAlt: 'Gallery photo',
|
||
videoAlt: 'Gallery video',
|
||
delete: 'Delete media',
|
||
deleteTitle: 'Delete Media?',
|
||
deleteBody: 'This photo or video will be permanently deleted.',
|
||
deleted: 'Media deleted.',
|
||
zoomIn: 'Zoom in',
|
||
zoomOut: 'Zoom out',
|
||
resetZoom: 'Reset zoom',
|
||
filters: { all: 'All', photos: 'Photos', videos: 'Videos' },
|
||
errors: {
|
||
cancelled: 'The media action was cancelled.',
|
||
capture_failed: 'Unable to capture the game view.',
|
||
invalid_media_type: 'The media type is invalid.',
|
||
invalid_upload: 'The upload could not be verified.',
|
||
invalid_upload_token: 'The upload session is no longer valid.',
|
||
missing_config: 'Gallery uploads are not configured.',
|
||
not_found: 'The media item no longer exists.',
|
||
operation_in_progress:
|
||
'Another media operation is already in progress.',
|
||
owner_changed: 'The active phone account changed.',
|
||
rate_limited: 'Too many media actions. Try again shortly.',
|
||
request_failed: 'The Gallery request failed.',
|
||
request_timeout: 'The media service timed out.',
|
||
unsupported: 'This media format is not supported.',
|
||
upload_failed: 'The media upload failed.',
|
||
upload_timeout: 'The media upload timed out.',
|
||
},
|
||
},
|
||
settings: {
|
||
name: 'Settings',
|
||
searchPlaceholder: 'Search',
|
||
airplaneMode: 'Airplane Mode',
|
||
streamerMode: 'Streamer Mode',
|
||
wallpaper: 'Wallpaper',
|
||
on: 'On',
|
||
off: 'Off',
|
||
accountName: 'iFruit Account',
|
||
accountDetail: 'Important Data & Cloud',
|
||
accountLocalDetail: 'Not signed in',
|
||
accountCloudDetail: 'Important data syncs through iFruit Cloud',
|
||
accountLoginBody:
|
||
'Sign in to keep your important data safe. Without an iFruit Account, it will be lost with the phone.',
|
||
accountInformation: 'Account Information',
|
||
accountStatus: 'Account Status',
|
||
accountStatusValue: 'Active',
|
||
accountStorage: 'Cloud Storage',
|
||
accountStorageValue: 'On Device',
|
||
accountPurchases: 'Media & Purchases',
|
||
accountPurchasesValue: 'Available',
|
||
notifications: 'Notifications',
|
||
sounds: 'Sounds & Haptics',
|
||
general: 'General Settings',
|
||
appearance: 'Appearance',
|
||
allowNotifications: 'Allow Notifications',
|
||
notificationSounds: 'Sounds',
|
||
notificationDuration: 'Notification Duration',
|
||
seconds: '{seconds} seconds',
|
||
ringtoneVolume: 'Ringtone Volume',
|
||
notificationVolume: 'Notification Volume',
|
||
ringtone: 'Ringtone',
|
||
notificationSound: 'Notification Sound',
|
||
appearanceMode: 'Appearance Mode',
|
||
automatic: 'Automatic',
|
||
light: 'Light',
|
||
dark: 'Dark',
|
||
phoneScale: 'Phone Scale',
|
||
phoneFrame: 'Phone Frame',
|
||
about: 'About',
|
||
deviceName: 'Device Name',
|
||
deviceNameValue: 'Sky Phone',
|
||
softwareVersion: 'Software Version',
|
||
language: 'Language',
|
||
languageValue: 'English',
|
||
localStorage: 'Local Storage',
|
||
localStorageValue: 'On Device',
|
||
deviceInformation: 'Device Information',
|
||
imei: 'IMEI',
|
||
simCard: 'SIM Card',
|
||
simNumber: 'Phone Number',
|
||
simType: 'SIM Type',
|
||
registeredSim: 'Registered',
|
||
anonymousSim: 'Anonymous',
|
||
noSim: 'No SIM',
|
||
ejectSim: 'Eject SIM',
|
||
ejectSimBody: 'Return this SIM card to your inventory?',
|
||
linkedDevices: 'Linked Devices',
|
||
thisDevice: 'This Phone',
|
||
removeDevice: 'Remove Device',
|
||
removeDeviceBody:
|
||
'Enter your iFruit password to remove this device from the account.',
|
||
signOut: 'Sign Out',
|
||
factoryReset: 'Erase All Content and Settings',
|
||
factoryResetBody:
|
||
'This removes the account and all local data from this phone. Cloud data and the IMEI remain.',
|
||
factoryResetProgress: 'Erasing iFruit Phone',
|
||
factoryResetWarning: 'Do not turn off this phone. This takes 60 seconds.',
|
||
accountErrors: {
|
||
invalid_email: 'Choose a valid 3–32 character iFruit address.',
|
||
invalid_password: 'Password must be 6–64 characters.',
|
||
invalid_credentials: 'Email or password is incorrect.',
|
||
email_taken: 'That iFruit address is already registered.',
|
||
rate_limited: 'Too many attempts. Try again in a minute.',
|
||
current_device: 'Sign out instead of removing the current phone.',
|
||
device_not_found: 'That device is no longer linked.',
|
||
default: 'The account request failed.',
|
||
},
|
||
back: 'Settings',
|
||
wallpaperPicker: 'Built-in Wallpapers',
|
||
toggle: {
|
||
airplaneMode: 'Toggle Airplane Mode',
|
||
streamerMode: 'Toggle Streamer Mode',
|
||
notifications: 'Toggle notifications for {app}',
|
||
notificationSounds: 'Toggle notification sounds for {app}',
|
||
},
|
||
frames: {
|
||
black: 'Black',
|
||
blue: 'Blue',
|
||
green: 'Green',
|
||
lavender: 'Lavender',
|
||
white: 'White',
|
||
},
|
||
ringtones: {
|
||
skyline: 'Skyline',
|
||
horizon: 'Horizon',
|
||
pulse: 'Pulse',
|
||
},
|
||
notificationSoundsList: {
|
||
chime: 'Chime',
|
||
signal: 'Signal',
|
||
soft: 'Soft',
|
||
},
|
||
wallpapers: {
|
||
midnight: 'Midnight wallpaper',
|
||
aurora: 'Aurora wallpaper',
|
||
ember: 'Ember wallpaper',
|
||
},
|
||
},
|
||
},
|
||
Common: {
|
||
add: 'Add',
|
||
cancel: 'Cancel',
|
||
clear: 'Clear',
|
||
close: 'Close',
|
||
back: 'Back',
|
||
delete: 'Delete',
|
||
done: 'Done',
|
||
edit: 'Edit',
|
||
home: 'Home',
|
||
pause: 'Pause',
|
||
phone: 'Phone',
|
||
phoneStatus: 'Phone status',
|
||
reset: 'Reset',
|
||
loading: 'Loading',
|
||
search: 'Search',
|
||
save: 'Save',
|
||
send: 'Send',
|
||
start: 'Start',
|
||
stop: 'Stop',
|
||
},
|
||
Notifications: { now: 'now' },
|
||
LockScreen: {
|
||
label: 'Lock Screen',
|
||
flashlight: 'Flashlight',
|
||
camera: 'Camera',
|
||
swipeUp: 'Swipe up to open',
|
||
},
|
||
Home: {
|
||
appLibrary: 'App Library',
|
||
appLibrarySearch: 'Search apps',
|
||
allApps: 'All Apps',
|
||
apps: 'Apps',
|
||
dock: 'Dock',
|
||
noApps: 'No apps found',
|
||
removeApp: 'Remove {app} from Home Screen',
|
||
page: 'Page',
|
||
pages: 'Home screen pages',
|
||
groups: {
|
||
suggestions: 'Suggestions',
|
||
recentlyAdded: 'Recently Added',
|
||
games: 'Games',
|
||
productivity: 'Productivity',
|
||
shopping: 'Shopping',
|
||
social: 'Social Networks',
|
||
utilities: 'Utilities',
|
||
},
|
||
widgets: {
|
||
label: 'Widgets',
|
||
weather: { city: 'Los Santos', condition: 'Partly Cloudy' },
|
||
calendar: { event: 'No more events today' },
|
||
battery: { label: 'Phone' },
|
||
media: {
|
||
title: 'Night Drive',
|
||
artist: 'Sky Radio',
|
||
play: 'Play',
|
||
pause: 'Pause',
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
function getByPath(source: LocaleTree, path: string): unknown {
|
||
return path.split('.').reduce<unknown>((current, segment) => {
|
||
if (
|
||
typeof current !== 'object' ||
|
||
current === null ||
|
||
Array.isArray(current)
|
||
)
|
||
return undefined
|
||
return (current as LocaleTree)[segment]
|
||
}, source)
|
||
}
|
||
|
||
export const usePhoneStore = defineStore('phone', {
|
||
state: () => ({
|
||
cameraLandscape: false,
|
||
currentPage: 1,
|
||
device: null as PhoneDevice | null,
|
||
deviceRevisions: {} as Record<string, number>,
|
||
isOpen: false,
|
||
lang: 'en',
|
||
launchOrigin: null as AppLaunchOrigin | null,
|
||
locales: defaultLocales,
|
||
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
|
||
systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||
}),
|
||
getters: {
|
||
isDarkMode(state): boolean {
|
||
if (state.preferences.settings.appearanceMode === 'dark') return true
|
||
if (state.preferences.settings.appearanceMode === 'light') return false
|
||
return state.systemDarkMode
|
||
},
|
||
},
|
||
actions: {
|
||
close(): void {
|
||
this.cameraLandscape = false
|
||
this.isOpen = false
|
||
},
|
||
open(payload: PhoneOpenPayload = {}): void {
|
||
this.lang = payload.lang ?? 'en'
|
||
this.locales = payload.locales ?? defaultLocales
|
||
if (payload.device) this.hydrateDevice(payload.device)
|
||
this.isOpen = true
|
||
},
|
||
hydrateDevice(device: PhoneDevice): void {
|
||
this.device = device
|
||
this.deviceRevisions = Object.fromEntries(
|
||
Object.entries(device.data).map(([key, value]) => [
|
||
key,
|
||
value?.revision ?? 0,
|
||
]),
|
||
)
|
||
this.preferences = parsePhonePreferences(
|
||
JSON.stringify(device.data.settings?.payload ?? null),
|
||
)
|
||
},
|
||
saveDeviceNamespace(namespace: string, payload: unknown): void {
|
||
const previous = namespaceQueues.get(namespace) ?? Promise.resolve()
|
||
const queued = previous.then(async () => {
|
||
const response = await nuiCall<{ revision: number }>('device:save', {
|
||
namespace,
|
||
payload,
|
||
revision: this.deviceRevisions[namespace] ?? 0,
|
||
})
|
||
if (response.success && response.data) {
|
||
this.deviceRevisions[namespace] = response.data.revision
|
||
}
|
||
})
|
||
const tracked = queued.finally(() => {
|
||
if (namespaceQueues.get(namespace) === tracked)
|
||
namespaceQueues.delete(namespace)
|
||
})
|
||
namespaceQueues.set(namespace, tracked)
|
||
},
|
||
setCurrentPage(page: number, pageCount?: number): void {
|
||
this.currentPage = clampPage(page, pageCount)
|
||
},
|
||
setCameraLandscape(landscape: boolean): void {
|
||
this.cameraLandscape = landscape
|
||
},
|
||
setLaunchOrigin(origin: AppLaunchOrigin | null): void {
|
||
this.launchOrigin = origin
|
||
},
|
||
setAppNotification(
|
||
appId: LaunchablePhoneAppId,
|
||
key: keyof AppNotificationPreferences,
|
||
value: boolean,
|
||
): void {
|
||
this.preferences.settings.notifications[appId][key] = value
|
||
this.saveDeviceNamespace('settings', this.preferences)
|
||
},
|
||
setPreference<K extends keyof PhonePreferencesV1['settings']>(
|
||
key: K,
|
||
value: PhonePreferencesV1['settings'][K],
|
||
): void {
|
||
this.preferences.settings[key] = value
|
||
this.saveDeviceNamespace('settings', this.preferences)
|
||
},
|
||
setSystemDarkMode(value: boolean): void {
|
||
this.systemDarkMode = value
|
||
},
|
||
setWallpaper(wallpaper: WallpaperId): void {
|
||
this.preferences.settings.wallpaper = wallpaper
|
||
this.saveDeviceNamespace('settings', this.preferences)
|
||
},
|
||
t(path: string, replacements: Record<string, string> = {}): string {
|
||
const translated = getByPath(this.locales, path)
|
||
const fallback = getByPath(defaultLocales, path)
|
||
const value =
|
||
typeof translated === 'string'
|
||
? translated
|
||
: typeof fallback === 'string'
|
||
? fallback
|
||
: path
|
||
return Object.entries(replacements).reduce(
|
||
(result, [key, replacement]) =>
|
||
result.split(`{${key}}`).join(replacement),
|
||
value,
|
||
)
|
||
},
|
||
},
|
||
})
|