feat: better theming, UI fixes, bug fixes

This commit is contained in:
2026-05-13 18:20:57 -04:00
Verified
parent 0d6ab45abc
commit 1b2a45cae2
16 changed files with 1448 additions and 795 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Jumpstart", "name": "Jumpstart",
"slug": "remote-wol", "slug": "remote-wol",
"version": "1.0.1", "version": "1.0.2",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "remotewol-upsnap", "scheme": "remotewol-upsnap",
+2 -3
View File
@@ -1,10 +1,9 @@
import { NativeTabs } from 'expo-router/unstable-native-tabs'; import { NativeTabs } from 'expo-router/unstable-native-tabs';
import React from 'react';
export default function TabsLayout() { export default function TabLayout() {
return ( return (
<NativeTabs> <NativeTabs>
<NativeTabs.Trigger name="index"> <NativeTabs.Trigger name="devices">
<NativeTabs.Trigger.Icon sf="desktopcomputer" md="home" /> <NativeTabs.Trigger.Icon sf="desktopcomputer" md="home" />
<NativeTabs.Trigger.Label>Devices</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>Devices</NativeTabs.Trigger.Label>
</NativeTabs.Trigger> </NativeTabs.Trigger>
+43
View File
@@ -0,0 +1,43 @@
import { Stack, useRouter } from 'expo-router';
import { useColorScheme } from '../../../hooks/use-color-scheme';
import { TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '@/src/context/AuthContext';
function Header() {
const router = useRouter();
const isDark = useColorScheme() === 'dark';
const activityColor = isDark ? '#0A84FF' : '#007AFF';
const { canCreate } = useAuth();
return (
<TouchableOpacity
onPress={() => router.push('/scan-devices')}
disabled={!canCreate}
style={{ paddingHorizontal: 8 }}
>
<Ionicons
name="add-circle-outline"
size={24}
color={canCreate ? activityColor : 'gray'}
/>
</TouchableOpacity>
);
}
export default function Layout() {
const isDark = useColorScheme() === 'dark';
const titleColor = isDark ? '#fff' : '#000';
return (
<Stack
screenOptions={{
title: 'Devices',
headerLargeTitle: true,
headerRight: () => <Header />,
headerTintColor: titleColor,
headerLargeTitleStyle: { color: titleColor },
}}
/>
);
}
@@ -1,8 +1,4 @@
import { import { Button, ContextMenu, Host } from '@expo/ui/swift-ui';
Button,
ContextMenu,
Host,
} from '@expo/ui/swift-ui';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import * as Burnt from 'burnt'; import * as Burnt from 'burnt';
import { SymbolView } from 'expo-symbols'; import { SymbolView } from 'expo-symbols';
@@ -18,10 +14,11 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import { useColorScheme } from '../../hooks/use-color-scheme'; import { useColorScheme } from '../../../hooks/use-color-scheme';
import api from '../../src/services/api'; import api from '../../../src/services/api';
import { syncDevicesToWidget } from '../../src/services/widgetSync'; import { syncDevicesToWidget } from '../../../src/services/widgetSync';
import { Device } from '../../src/types'; import { Device } from '../../../src/types';
import { useAuth } from '@/src/context/AuthContext';
const isAuthError = (error: unknown) => const isAuthError = (error: unknown) =>
typeof error === 'object' && typeof error === 'object' &&
@@ -42,6 +39,8 @@ export default function DeviceListScreen() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const { isAuthenticated } = useAuth();
const fetchDevices = useCallback(async (showLoading = false) => { const fetchDevices = useCallback(async (showLoading = false) => {
try { try {
if (showLoading) setIsLoading(true); if (showLoading) setIsLoading(true);
@@ -65,10 +64,14 @@ export default function DeviceListScreen() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return;
fetchDevices(true); fetchDevices(true);
}, [fetchDevices]); }, [fetchDevices, isAuthenticated]);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return;
let intervalId: number | null = null; let intervalId: number | null = null;
const startPolling = () => { const startPolling = () => {
@@ -102,7 +105,7 @@ export default function DeviceListScreen() {
stopPolling(); stopPolling();
subscription.remove(); subscription.remove();
}; };
}, [fetchDevices]); }, [fetchDevices, isAuthenticated]);
const onRefresh = async () => { const onRefresh = async () => {
setRefreshing(true); setRefreshing(true);
@@ -259,17 +262,20 @@ export default function DeviceListScreen() {
symbolName, symbolName,
color, color,
onPress, onPress,
enabled,
fallbackName, fallbackName,
}: { }: {
name: string; name: string;
symbolName: string; symbolName: string;
color: string; color: string;
onPress: () => void; onPress: () => void;
enabled: boolean;
fallbackName?: string; fallbackName?: string;
}) => ( }) => (
<TouchableOpacity <TouchableOpacity
style={styles.actionIconContainer} style={styles.actionIconContainer}
onPress={onPress} onPress={onPress}
disabled={!enabled}
accessibilityLabel={name} accessibilityLabel={name}
activeOpacity={0.75} activeOpacity={0.75}
> >
@@ -293,29 +299,28 @@ export default function DeviceListScreen() {
const renderDevice = ({ item }: { item: Device }) => { const renderDevice = ({ item }: { item: Device }) => {
const isOnline = item.status?.toLowerCase() === 'online'; const isOnline = item.status?.toLowerCase() === 'online';
const isOffline = item.status?.toLowerCase() === 'offline'; const isOffline = item.status?.toLowerCase() === 'offline';
const hasActions = isOnline || isOffline;
return ( return (
<View <Host>
style={[ <ContextMenu>
styles.deviceCard, <ContextMenu.Items>
{ <Button
backgroundColor: cardBg, systemImage="trash"
shadowColor: isDark ? 'rgba(0,0,0,0.6)' : '#000', role="destructive"
}, label="Delete Device"
]} onPress={() => handleDelete(item)}
> />
<Host> </ContextMenu.Items>
<ContextMenu> <ContextMenu.Trigger>
<ContextMenu.Items> <View
<Button style={[
systemImage="trash" styles.deviceCard,
role="destructive" {
label='Delete Device' backgroundColor: cardBg,
onPress={() => handleDelete(item)} shadowColor: isDark ? 'rgba(0,0,0,0.6)' : '#000',
/> },
</ContextMenu.Items> ]}
<ContextMenu.Trigger> >
<View style={styles.deviceHeader}> <View style={styles.deviceHeader}>
<View style={styles.deviceInfo}> <View style={styles.deviceInfo}>
<Text style={[styles.deviceName, { color: textColor }]}> <Text style={[styles.deviceName, { color: textColor }]}>
@@ -351,49 +356,45 @@ export default function DeviceListScreen() {
]} ]}
/> />
</View> </View>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
{hasActions && ( <View style={styles.deviceActions}>
<View style={styles.deviceActions}> <ActionIcon
{isOffline && ( name="Wake"
<ActionIcon enabled={isOffline}
name="Wake" symbolName="bolt.circle.fill"
symbolName="bolt.circle.fill" fallbackName="flash"
fallbackName="flash" color={isOffline ? '#4CAF50' : subTextColor}
color="#4CAF50" onPress={() => handleWake(item)}
onPress={() => handleWake(item)} />
/>
)}
{isOnline && (
<>
<ActionIcon <ActionIcon
name="Sleep" name="Sleep"
enabled={isOnline && item.sol_enabled}
symbolName="moon.circle.fill" symbolName="moon.circle.fill"
fallbackName="moon" fallbackName="moon"
color="#FF9800" color={isOnline && item.sol_enabled ? '#FF9800' : subTextColor}
onPress={() => handleSleep(item)} onPress={() => handleSleep(item)}
/> />
<ActionIcon <ActionIcon
name="Reboot" name="Reboot"
enabled={isOnline && item.shutdown_cmd !== "" }
symbolName="arrow.clockwise.circle.fill" symbolName="arrow.clockwise.circle.fill"
fallbackName="refresh" fallbackName="refresh"
color="#2196F3" color={isOnline && item.shutdown_cmd !== "" ? '#2196F3' : subTextColor}
onPress={() => handleReboot(item)} onPress={() => handleReboot(item)}
/> />
<ActionIcon <ActionIcon
name="Shutdown" name="Shutdown"
enabled={isOnline && item.shutdown_cmd !== "" }
symbolName="power.circle.fill" symbolName="power.circle.fill"
fallbackName="power" fallbackName="power"
color="#f44336" color={isOnline && item.shutdown_cmd !== "" ? '#f44336' : subTextColor}
onPress={() => handleShutdown(item)} onPress={() => handleShutdown(item)}
/> />
</> </View>
)} </View>
</View> </ContextMenu.Trigger>
)} </ContextMenu>
</View> </Host>
); );
}; };
@@ -406,26 +407,28 @@ export default function DeviceListScreen() {
} }
return ( return (
<View style={[styles.container, { backgroundColor: bgColor }]}> <FlatList
<FlatList style={[styles.container, { backgroundColor: bgColor }]}
data={devices} data={devices}
renderItem={renderDevice} renderItem={renderDevice}
keyExtractor={item => item.id} keyExtractor={item => item.id}
contentContainerStyle={styles.list} contentContainerStyle={styles.list}
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
refreshControl={ refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} /> <RefreshControl
} refreshing={refreshing}
ListFooterComponent={<View style={{ height: 20 }} />} onRefresh={onRefresh}
ListEmptyComponent={ tintColor={isDark ? subTextColor : undefined}
<View style={styles.emptyContainer}> />
<Text style={[styles.emptyText, { color: subTextColor }]}> }
No devices found ListEmptyComponent={
</Text> <View style={styles.emptyContainer}>
</View> <Text style={[styles.emptyText, { color: subTextColor }]}>
} No devices found
/> </Text>
</View> </View>
}
/>
); );
} }
+18
View File
@@ -0,0 +1,18 @@
import { Stack } from 'expo-router';
import { useColorScheme } from '../../../hooks/use-color-scheme';
export default function Layout() {
const isDark = useColorScheme() === 'dark';
const titleColor = isDark ? '#fff' : '#000';
return (
<Stack
screenOptions={{
title: 'Settings',
headerLargeTitle: true,
headerTintColor: titleColor,
headerLargeTitleStyle: { color: titleColor },
}}
/>
);
}
@@ -7,8 +7,8 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import { useColorScheme } from '../../hooks/use-color-scheme'; import { useColorScheme } from '../../../hooks/use-color-scheme';
import { useAuth } from '../../src/context/AuthContext'; import { useAuth } from '../../../src/context/AuthContext';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
@@ -90,7 +90,7 @@ export default function SettingsScreen() {
); );
return ( return (
<ScrollView style={[styles.container, { backgroundColor: bgColor }]}> <ScrollView style={[styles.container, { backgroundColor: bgColor }]} contentInsetAdjustmentBehavior='automatic'>
<View style={styles.content}> <View style={styles.content}>
<View <View
style={[ style={[
@@ -109,8 +109,7 @@ export default function SettingsScreen() {
> >
Information Information
</Text> </Text>
<SettingItem label="Username" value={user?.username || 'Admin'} /> <SettingItem label={user?.email ? 'Email' : 'Username'} value={user?.email || user?.username} />
<SettingItem label="Email" value={user?.email || 'N/A'} />
<SettingItem label="Server URL" value={serverAddress || 'N/A'} /> <SettingItem label="Server URL" value={serverAddress || 'N/A'} />
<SettingItem label="App Version" value={`${Constants.expoConfig?.version}`} /> <SettingItem label="App Version" value={`${Constants.expoConfig?.version}`} />
</View> </View>
@@ -137,7 +136,7 @@ export default function SettingsScreen() {
<View style={styles.footer}> <View style={styles.footer}>
<Text style={[styles.footerText, { color: subTextColor }]}> <Text style={[styles.footerText, { color: subTextColor }]}>
UpSnap Mobile App Jumpstart
</Text> </Text>
<Text style={[styles.footerText, { color: subTextColor }]}> <Text style={[styles.footerText, { color: subTextColor }]}>
Connect to your UpSnap server Connect to your UpSnap server
+14 -66
View File
@@ -1,108 +1,56 @@
import { Ionicons } from '@expo/vector-icons';
import { Stack, useRouter, useSegments } from 'expo-router'; import { Stack, useRouter, useSegments } from 'expo-router';
import { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Text, TouchableOpacity } from 'react-native';
import { useColorScheme } from '../hooks/use-color-scheme'; import { useColorScheme } from '../hooks/use-color-scheme';
import { AuthProvider, useAuth } from '../src/context/AuthContext'; import { AuthProvider, useAuth } from '../src/context/AuthContext';
function AuthRedirect() { function AuthRedirect() {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter(); const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const { isAuthenticated, isLoading } = useAuth();
const firstSegment = segments[0];
useEffect(() => { useEffect(() => {
if (isLoading) { if (isLoading) {
return; return;
} }
const isLoginRoute = segments[0] === 'login'; const isLoginRoute = firstSegment === 'login';
if (!isAuthenticated && !isLoginRoute) { if (!isAuthenticated && !isLoginRoute) {
router.replace('/login'); router.replace('/login');
} else if (isAuthenticated && isLoginRoute) {
router.replace('/devices');
} }
}, [firstSegment, isAuthenticated, isLoading, router]);
if (isAuthenticated && isLoginRoute) {
router.replace('/(tabs)');
}
}, [isAuthenticated, isLoading, router, segments]);
return null; return null;
} }
function DevicesHeader() {
const router = useRouter();
const isDark = useColorScheme() === 'dark';
const activityColor = isDark ? '#0A84FF' : '#007AFF';
const { canCreate } = useAuth();
return (
<TouchableOpacity
onPress={() => router.push('/scan-devices')}
disabled={!canCreate}
style={{ paddingHorizontal: 8 }}
>
<Ionicons name="add-circle-outline" size={24} color={canCreate ? activityColor : 'gray'} />
</TouchableOpacity>
);
}
function TabsTitle(props: { title: string }) {
const segments = useSegments();
const last = segments[segments.length - 1];
const isDark = useColorScheme() === 'dark';
const titleColor = isDark ? '#fff' : '#000';
const title = last === 'settings' ? 'Settings' : props.title;
return (
<Text style={{ color: titleColor, fontSize: 17, fontWeight: '600' }}>
{title}
</Text>
);
}
export default function RootLayout() { export default function RootLayout() {
const isDark = useColorScheme() === 'dark'; const isDark = useColorScheme() === 'dark';
const bgColor = isDark ? '#0b0b0d' : '#fff'; const bgColor = isDark ? '#0b0b0d' : '#fff';
const titleColor = isDark ? '#fff' : '#000'; const titleColor = isDark ? '#fff' : '#000';
const segments = useSegments();
const last = segments[segments.length - 1];
const isIndex = last === '(tabs)' || last === undefined;
return ( return (
<AuthProvider> <AuthProvider>
<AuthRedirect /> <AuthRedirect />
<Stack> <Stack screenOptions={{ headerShown: false }}>
{/* Root index that performs auth redirect (app/index.tsx) */} <Stack.Screen name="index" />
<Stack.Screen name="index" options={{ headerShown: false }} /> <Stack.Screen name="login" />
{/* Tabs parent - render tabs with dynamic header */} <Stack.Screen name="(tabs)" />
<Stack.Screen
name="(tabs)"
options={
{
headerTitle: () => <TabsTitle title="Devices" />,
headerRight: isIndex ? () => <DevicesHeader /> : undefined,
headerRightContainerStyle: isIndex
? undefined
: { width: 0, paddingRight: 0 },
headerStyle: { backgroundColor: bgColor },
headerTintColor: titleColor,
} as any
}
/>{' '}
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen <Stack.Screen
name="scan-devices" name="scan-devices"
options={{ options={{
headerShown: true, headerShown: true,
headerTitle: () => <TabsTitle title="Scan Devices" />, title: 'Scan for Devices',
headerStyle: { backgroundColor: bgColor }, headerStyle: { backgroundColor: bgColor },
headerTintColor: titleColor,
headerBackButtonDisplayMode: 'minimal', headerBackButtonDisplayMode: 'minimal',
}} }}
/> />
{/* Deep link action handler - no header, immediately redirects */}
<Stack.Screen <Stack.Screen
name="action/[action]/[deviceId]" name="action/[action]/[deviceId]"
options={{ headerShown: false, animation: 'none' }} options={{ animation: 'none' }}
/> />
</Stack> </Stack>
</AuthProvider> </AuthProvider>
+2 -2
View File
@@ -20,11 +20,11 @@ export default function ActionHandler() {
executeAction(action as DeviceAction, deviceId); executeAction(action as DeviceAction, deviceId);
} }
// Reset navigation to tabs - clears entire stack so no back button // Reset navigation to devices - clears entire stack so no back button
navigation.dispatch( navigation.dispatch(
CommonActions.reset({ CommonActions.reset({
index: 0, index: 0,
routes: [{ name: '(tabs)' }], routes: [{ name: 'devices' }],
}) })
); );
}, [action, deviceId, navigation]); }, [action, deviceId, navigation]);
+5 -13
View File
@@ -1,21 +1,13 @@
import { useRouter } from "expo-router"; import { Redirect } from "expo-router";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import React, { useEffect } from "react";
import { useAuth } from "../src/context/AuthContext"; import { useAuth } from "../src/context/AuthContext";
export default function IndexRedirect() { export default function IndexRedirect() {
const router = useRouter();
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
useEffect(() => { if (isLoading) {
if (!isLoading) { return <StatusBar style="auto" />;
if (isAuthenticated) { }
router.replace("/(tabs)");
} else {
router.replace("/login");
}
}
}, [isAuthenticated, isLoading, router]);
return <StatusBar style="auto" />; return <Redirect href={isAuthenticated ? "/devices" : "/login"} />;
} }
+2 -1
View File
@@ -155,7 +155,8 @@ export default function ScanDevicesScreen() {
<Text style={[styles.infoText, { color: subText }]}> <Text style={[styles.infoText, { color: subText }]}>
Note: This requires the server to have nmap installed and may take Note: This requires the server to have nmap installed and may take
several minutes. several minutes. To allow for shutdown, reboot, and sleep actions,
please configure their respective commands in your UpSnap server.
</Text> </Text>
{devices.length > 0 && ( {devices.length > 0 && (
+1232 -596
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -12,35 +12,35 @@
}, },
"dependencies": { "dependencies": {
"@bacons/apple-targets": "^3.0.6", "@bacons/apple-targets": "^3.0.6",
"@expo/ui": "55.0.1", "@expo/ui": "~55.0.15",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.2.0", "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"burnt": "^0.13.0", "burnt": "^0.13.0",
"expo": "^55.0.5", "expo": "^55.0.23",
"expo-constants": "~55.0.7", "expo-constants": "~55.0.7",
"expo-font": "~55.0.4", "expo-font": "~55.0.4",
"expo-glass-effect": "~55.0.7", "expo-glass-effect": "~55.0.11",
"expo-haptics": "~55.0.8", "expo-haptics": "~55.0.14",
"expo-image": "~55.0.6", "expo-image": "~55.0.10",
"expo-linking": "~55.0.7", "expo-linking": "~55.0.15",
"expo-router": "~55.0.4", "expo-router": "~55.0.14",
"expo-splash-screen": "~55.0.10", "expo-splash-screen": "~55.0.20",
"expo-status-bar": "~55.0.4", "expo-status-bar": "~55.0.6",
"expo-symbols": "~55.0.5", "expo-symbols": "~55.0.8",
"expo-system-ui": "~55.0.9", "expo-system-ui": "~55.0.17",
"expo-web-browser": "~55.0.9", "expo-web-browser": "~55.0.15",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-native": "0.83.2", "react-native": "0.83.6",
"react-native-gesture-handler": "~2.30.0", "react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "4.2.1", "react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.23.0", "react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.7.2" "react-native-worklets": "0.7.4"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.10", "@types/react": "~19.2.10",
+4
View File
@@ -13,6 +13,8 @@ interface WidgetDevice {
name: string; name: string;
mac: string; mac: string;
ip: string; ip: string;
canSleep: boolean;
canShutdown: boolean;
status: string; status: string;
} }
@@ -32,6 +34,8 @@ export function syncDevicesToWidget(devices: Device[]): void {
name: device.name, name: device.name,
mac: device.mac, mac: device.mac,
ip: device.ip, ip: device.ip,
canSleep: device.sol_enabled,
canShutdown: device.shutdown_cmd !== "",
status: device.status || 'unknown', status: device.status || 'unknown',
})); }));
+4 -2
View File
@@ -7,8 +7,10 @@ export interface Device {
ip: string; ip: string;
netmask: string; netmask: string;
broadcast: string; broadcast: string;
secureOnPassword: string; password: string;
port: number; shutdown_cmd: string;
sol_enabled: boolean;
ports: number[];
groups: string[]; groups: string[];
status: string; status: string;
created: string; created: string;
+2
View File
@@ -32,6 +32,8 @@ struct DeviceInfo: Codable, Hashable, Identifiable {
let name: String let name: String
let mac: String let mac: String
let ip: String let ip: String
let canSleep: Bool
let canShutdown: Bool
let status: String let status: String
} }
+20 -14
View File
@@ -16,7 +16,7 @@ struct Provider: AppIntentTimelineProvider {
DeviceEntry( DeviceEntry(
date: Date(), date: Date(),
configuration: ConfigurationAppIntent(), configuration: ConfigurationAppIntent(),
device: DeviceInfo(id: "placeholder", name: "My Computer", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", status: "unknown") device: DeviceInfo(id: "placeholder", name: "My Computer", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", canSleep: true, canShutdown: true, status: "unknown")
) )
} }
@@ -76,21 +76,21 @@ struct DeviceWidgetEntryView: View {
// Small widget: 2x2 grid // Small widget: 2x2 grid
VStack(spacing: 6) { VStack(spacing: 6) {
HStack(spacing: 6) { HStack(spacing: 6) {
ActionButton(action: .wake, deviceId: device.id) ActionButton(action: .wake, deviceId: device.id, enabled: device.status == "offline")
ActionButton(action: .sleep, deviceId: device.id) ActionButton(action: .sleep, deviceId: device.id, enabled: device.status == "online" && device.canSleep)
} }
HStack(spacing: 6) { HStack(spacing: 6) {
ActionButton(action: .restart, deviceId: device.id) ActionButton(action: .restart, deviceId: device.id, enabled: device.status == "online" && device.canShutdown)
ActionButton(action: .shutdown, deviceId: device.id) ActionButton(action: .shutdown, deviceId: device.id, enabled: device.status == "online" && device.canShutdown)
} }
} }
} else { } else {
// Medium/Large widget: horizontal layout // Medium/Large widget: horizontal layout
HStack(spacing: 8) { HStack(spacing: 8) {
ActionButton(action: .wake, deviceId: device.id) ActionButton(action: .wake, deviceId: device.id, enabled: device.status == "offline")
ActionButton(action: .sleep, deviceId: device.id) ActionButton(action: .sleep, deviceId: device.id, enabled: device.status == "online" && device.canSleep)
ActionButton(action: .restart, deviceId: device.id) ActionButton(action: .restart, deviceId: device.id, enabled: device.status == "online" && device.canShutdown)
ActionButton(action: .shutdown, deviceId: device.id) ActionButton(action: .shutdown, deviceId: device.id, enabled: device.status == "online" && device.canShutdown)
} }
} }
} }
@@ -118,6 +118,7 @@ struct DeviceWidgetEntryView: View {
struct ActionButton: View { struct ActionButton: View {
let action: DeviceAction let action: DeviceAction
let deviceId: String let deviceId: String
let enabled: Bool
var icon: String { var icon: String {
switch action { switch action {
@@ -137,6 +138,10 @@ struct ActionButton: View {
} }
} }
var disabled: Color {
return .gray
}
var label: String { var label: String {
switch action { switch action {
case .wake: return "Wake" case .wake: return "Wake"
@@ -147,15 +152,16 @@ struct ActionButton: View {
} }
var body: some View { var body: some View {
Link(destination: action.url(for: deviceId)) { Link(destination: enabled ? action.url(for: deviceId) : URL("")!) {
VStack(spacing: 2) { VStack(spacing: 2) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 16, weight: .medium)) .font(.system(size: 16, weight: .medium))
.foregroundColor(color) .foregroundColor(enabled ? color : disabled)
} }
.frame(maxWidth: .infinity, minHeight: 36) .frame(maxWidth: .infinity, minHeight: 36)
.background(color.opacity(0.15)) .background((enabled ? color : disabled).opacity(0.15))
.cornerRadius(8) .cornerRadius(8)
.disabled(!enabled)
} }
} }
} }
@@ -187,7 +193,7 @@ typealias widget = DeviceControlWidget
DeviceEntry( DeviceEntry(
date: .now, date: .now,
configuration: ConfigurationAppIntent(), configuration: ConfigurationAppIntent(),
device: DeviceInfo(id: "preview", name: "Gaming PC", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", status: "online") device: DeviceInfo(id: "preview", name: "Gaming PC", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", canSleep: true, canShutdown: true, status: "online")
) )
} }
@@ -197,6 +203,6 @@ typealias widget = DeviceControlWidget
DeviceEntry( DeviceEntry(
date: .now, date: .now,
configuration: ConfigurationAppIntent(), configuration: ConfigurationAppIntent(),
device: DeviceInfo(id: "preview", name: "Home Server", mac: "11:22:33:44:55:66", ip: "192.168.1.50", status: "offline") device: DeviceInfo(id: "preview", name: "Home Server", mac: "11:22:33:44:55:66", ip: "192.168.1.50", canSleep: true, canShutdown: true, status: "offline")
) )
} }