feat(machine): omnipresent light/dark toggle and dynamic network badge

Move light/dark toggle with Lucide Sun/Moon icons from IdleView to
App.vue so it's visible on all screens. Detect Bitcoin network
(mainnet/testnet/regtest) from BOLT-11 invoice prefix and persist
in localStorage. Hide network badge on mainnet (implied).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-01 12:22:42 -05:00
commit 73feb2b0d2
2 changed files with 44 additions and 10 deletions

View file

@ -2,13 +2,14 @@
import { onMounted, onUnmounted, ref, computed } from 'vue' import { onMounted, onUnmounted, ref, computed } from 'vue'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { Sun, Moon } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
const atmStore = useAtmStore() const atmStore = useAtmStore()
const { current: currentTheme, themes, colorMode } = useTheme() const { current: currentTheme, themes, colorMode } = useTheme()
const debugExpanded = ref(false) const debugExpanded = ref(false)
const network = import.meta.env.VITE_NETWORK || 'regtest' // Network detected dynamically from Lightning invoice prefix
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
function formatSats(sats: number): string { function formatSats(sats: number): string {
@ -76,8 +77,8 @@ function toggleLiveServices() {
<line x1="12" y1="9" x2="12" y2="13" /> <line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" /> <line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
<h1 class="text-3xl font-bold">ATM Unavailable</h1> <h1 class="text-[3.5rem] font-bold">ATM Unavailable</h1>
<p class="max-w-md text-center text-lg text-muted-foreground"> <p class="max-w-md text-center text-2xl text-muted-foreground">
This machine is temporarily out of service. Please try again later or use another machine. This machine is temporarily out of service. Please try again later or use another machine.
</p> </p>
<p v-if="atmStore.debugMode" class="max-w-lg text-center font-mono text-sm text-destructive"> <p v-if="atmStore.debugMode" class="max-w-lg text-center font-mono text-sm text-destructive">
@ -89,18 +90,16 @@ function toggleLiveServices() {
<router-view /> <router-view />
<!-- Connection status + balance (top right) --> <!-- Connection status + balance (top right) -->
<div <div class="fixed right-6 top-6 z-50 flex flex-wrap items-center justify-end gap-2">
class="fixed right-2 top-2 z-50 flex flex-wrap items-center justify-end gap-1 sm:right-4 sm:top-4 sm:gap-2"
>
<Badge <Badge
v-if="formattedBtcPrice" v-if="formattedBtcPrice"
class="bg-primary/10 font-mono text-primary border border-primary/30 text-xs sm:text-sm" class="bg-primary/10 font-mono text-primary border border-primary/30 text-base"
> >
{{ formattedBtcPrice }} {{ formattedBtcPrice }}
</Badge> </Badge>
<Badge <Badge
v-if="atmStore.balanceSats !== null" v-if="atmStore.balanceSats !== null"
class="bg-bitcoin/20 font-mono text-bitcoin border border-bitcoin/30 text-xs sm:text-sm" class="bg-bitcoin/20 font-mono text-bitcoin border border-bitcoin/30 text-base"
> >
bal: {{ formatSats(atmStore.balanceSats) }} sats bal: {{ formatSats(atmStore.balanceSats) }} sats
</Badge> </Badge>
@ -121,13 +120,25 @@ function toggleLiveServices() {
</Badge> </Badge>
<Badge v-else class="bg-muted text-muted-foreground">Mock</Badge> <Badge v-else class="bg-muted text-muted-foreground">Mock</Badge>
<Badge <Badge
v-if="network !== 'mainnet'" v-if="atmStore.network && atmStore.network !== 'mainnet'"
class="bg-warning/20 text-warning border border-warning/30" class="bg-warning/20 text-warning border border-warning/30"
> >
{{ network }} {{ atmStore.network }}
</Badge> </Badge>
</div> </div>
<!-- Light/dark toggle (always visible) -->
<Button
variant="outline"
size="kiosk"
class="fixed bottom-6 right-6 z-50 gap-3"
@click="colorMode = colorMode === 'dark' ? 'light' : 'dark'"
>
<Sun v-if="colorMode === 'dark'" :size="28" />
<Moon v-else :size="28" />
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
</Button>
<!-- Debug overlay (dev only) --> <!-- Debug overlay (dev only) -->
<div <div
v-if="atmStore.debugMode" v-if="atmStore.debugMode"

View file

@ -151,12 +151,28 @@ export const useAtmStore = defineStore('atm', () => {
const balanceSats = ref<number | null>(null) const balanceSats = ref<number | null>(null)
const btcPrice = ref<number | null>(null) // local fiat per BTC (for display) const btcPrice = ref<number | null>(null) // local fiat per BTC (for display)
const btcPriceUsd = ref<number | null>(null) // USD per BTC (always shown) const btcPriceUsd = ref<number | null>(null) // USD per BTC (always shown)
const network = ref<'mainnet' | 'testnet' | 'regtest' | null>(
(localStorage.getItem('atm-network') as 'mainnet' | 'testnet' | 'regtest') || null
)
let stopBalanceWatch: (() => void) | null = null let stopBalanceWatch: (() => void) | null = null
let pricePollingInterval: ReturnType<typeof setInterval> | null = null let pricePollingInterval: ReturnType<typeof setInterval> | null = null
// Store reference to ATM services for direct calls // Store reference to ATM services for direct calls
let atmServicesRef: ATMServices | null = null let atmServicesRef: ATMServices | null = null
/** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */
function detectNetworkFromInvoice(invoice: string) {
if (network.value) return // already detected
const lower = invoice.toLowerCase()
if (lower.startsWith('lnbcrt')) network.value = 'regtest'
else if (lower.startsWith('lntb')) network.value = 'testnet'
else if (lower.startsWith('lnbc')) network.value = 'mainnet'
if (network.value) {
localStorage.setItem('atm-network', network.value)
console.log('[ATM] Detected network:', network.value)
}
}
// Computed // Computed
const currentState = computed(() => { const currentState = computed(() => {
if (!snapshot.value) return 'uninitialized' if (!snapshot.value) return 'uninitialized'
@ -220,6 +236,11 @@ export const useAtmStore = defineStore('atm', () => {
if ('cashOut' in state) currentNested = state.cashOut as string if ('cashOut' in state) currentNested = state.cashOut as string
} }
// Detect network from first invoice we see
if (newSnapshot.context.invoice) {
detectNetworkFromInvoice(newSnapshot.context.invoice)
}
// Auto-advance past waitingForCashTaken when HAL is connected, // Auto-advance past waitingForCashTaken when HAL is connected,
// because HAL's dispenseCash already calls waitForBillsRemoved() // because HAL's dispenseCash already calls waitForBillsRemoved()
if ( if (
@ -394,6 +415,7 @@ export const useAtmStore = defineStore('atm', () => {
paymentError.value = null paymentError.value = null
try { try {
detectNetworkFromInvoice(invoice)
console.log( console.log(
'[ATM] Paying invoice:', '[ATM] Paying invoice:',
invoice.slice(0, 40) + '...', invoice.slice(0, 40) + '...',
@ -999,6 +1021,7 @@ export const useAtmStore = defineStore('atm', () => {
balanceSats, balanceSats,
btcPrice, btcPrice,
btcPriceUsd, btcPriceUsd,
network,
halServices, halServices,
isPayingInvoice, isPayingInvoice,
isRequestingDebit, isRequestingDebit,