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": {
"name": "Jumpstart",
"slug": "remote-wol",
"version": "1.0.1",
"version": "1.0.2",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "remotewol-upsnap",
+2 -3
View File
@@ -1,10 +1,9 @@
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import React from 'react';
export default function TabsLayout() {
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger name="devices">
<NativeTabs.Trigger.Icon sf="desktopcomputer" md="home" />
<NativeTabs.Trigger.Label>Devices</NativeTabs.Trigger.Label>
</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 {
Button,
ContextMenu,
Host,
} from '@expo/ui/swift-ui';
import { Button, ContextMenu, Host } from '@expo/ui/swift-ui';
import { Ionicons } from '@expo/vector-icons';
import * as Burnt from 'burnt';
import { SymbolView } from 'expo-symbols';
@@ -18,10 +14,11 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import { useColorScheme } from '../../hooks/use-color-scheme';
import api from '../../src/services/api';
import { syncDevicesToWidget } from '../../src/services/widgetSync';
import { Device } from '../../src/types';
import { useColorScheme } from '../../../hooks/use-color-scheme';
import api from '../../../src/services/api';
import { syncDevicesToWidget } from '../../../src/services/widgetSync';
import { Device } from '../../../src/types';
import { useAuth } from '@/src/context/AuthContext';
const isAuthError = (error: unknown) =>
typeof error === 'object' &&
@@ -42,6 +39,8 @@ export default function DeviceListScreen() {
const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const { isAuthenticated } = useAuth();
const fetchDevices = useCallback(async (showLoading = false) => {
try {
if (showLoading) setIsLoading(true);
@@ -65,10 +64,14 @@ export default function DeviceListScreen() {
}, []);
useEffect(() => {
if (!isAuthenticated) return;
fetchDevices(true);
}, [fetchDevices]);
}, [fetchDevices, isAuthenticated]);
useEffect(() => {
if (!isAuthenticated) return;
let intervalId: number | null = null;
const startPolling = () => {
@@ -102,7 +105,7 @@ export default function DeviceListScreen() {
stopPolling();
subscription.remove();
};
}, [fetchDevices]);
}, [fetchDevices, isAuthenticated]);
const onRefresh = async () => {
setRefreshing(true);
@@ -259,17 +262,20 @@ export default function DeviceListScreen() {
symbolName,
color,
onPress,
enabled,
fallbackName,
}: {
name: string;
symbolName: string;
color: string;
onPress: () => void;
enabled: boolean;
fallbackName?: string;
}) => (
<TouchableOpacity
style={styles.actionIconContainer}
onPress={onPress}
disabled={!enabled}
accessibilityLabel={name}
activeOpacity={0.75}
>
@@ -293,9 +299,19 @@ export default function DeviceListScreen() {
const renderDevice = ({ item }: { item: Device }) => {
const isOnline = item.status?.toLowerCase() === 'online';
const isOffline = item.status?.toLowerCase() === 'offline';
const hasActions = isOnline || isOffline;
return (
<Host>
<ContextMenu>
<ContextMenu.Items>
<Button
systemImage="trash"
role="destructive"
label="Delete Device"
onPress={() => handleDelete(item)}
/>
</ContextMenu.Items>
<ContextMenu.Trigger>
<View
style={[
styles.deviceCard,
@@ -305,17 +321,6 @@ export default function DeviceListScreen() {
},
]}
>
<Host>
<ContextMenu>
<ContextMenu.Items>
<Button
systemImage="trash"
role="destructive"
label='Delete Device'
onPress={() => handleDelete(item)}
/>
</ContextMenu.Items>
<ContextMenu.Trigger>
<View style={styles.deviceHeader}>
<View style={styles.deviceInfo}>
<Text style={[styles.deviceName, { color: textColor }]}>
@@ -351,49 +356,45 @@ export default function DeviceListScreen() {
]}
/>
</View>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
{hasActions && (
<View style={styles.deviceActions}>
{isOffline && (
<ActionIcon
name="Wake"
enabled={isOffline}
symbolName="bolt.circle.fill"
fallbackName="flash"
color="#4CAF50"
color={isOffline ? '#4CAF50' : subTextColor}
onPress={() => handleWake(item)}
/>
)}
{isOnline && (
<>
<ActionIcon
name="Sleep"
enabled={isOnline && item.sol_enabled}
symbolName="moon.circle.fill"
fallbackName="moon"
color="#FF9800"
color={isOnline && item.sol_enabled ? '#FF9800' : subTextColor}
onPress={() => handleSleep(item)}
/>
<ActionIcon
name="Reboot"
enabled={isOnline && item.shutdown_cmd !== "" }
symbolName="arrow.clockwise.circle.fill"
fallbackName="refresh"
color="#2196F3"
color={isOnline && item.shutdown_cmd !== "" ? '#2196F3' : subTextColor}
onPress={() => handleReboot(item)}
/>
<ActionIcon
name="Shutdown"
enabled={isOnline && item.shutdown_cmd !== "" }
symbolName="power.circle.fill"
fallbackName="power"
color="#f44336"
color={isOnline && item.shutdown_cmd !== "" ? '#f44336' : subTextColor}
onPress={() => handleShutdown(item)}
/>
</>
)}
</View>
)}
</View>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
};
@@ -406,17 +407,20 @@ export default function DeviceListScreen() {
}
return (
<View style={[styles.container, { backgroundColor: bgColor }]}>
<FlatList
style={[styles.container, { backgroundColor: bgColor }]}
data={devices}
renderItem={renderDevice}
keyExtractor={item => item.id}
contentContainerStyle={styles.list}
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={isDark ? subTextColor : undefined}
/>
}
ListFooterComponent={<View style={{ height: 20 }} />}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: subTextColor }]}>
@@ -425,7 +429,6 @@ export default function DeviceListScreen() {
</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,
View,
} from 'react-native';
import { useColorScheme } from '../../hooks/use-color-scheme';
import { useAuth } from '../../src/context/AuthContext';
import { useColorScheme } from '../../../hooks/use-color-scheme';
import { useAuth } from '../../../src/context/AuthContext';
import { useRouter } from 'expo-router';
import Constants from 'expo-constants';
@@ -90,7 +90,7 @@ export default function SettingsScreen() {
);
return (
<ScrollView style={[styles.container, { backgroundColor: bgColor }]}>
<ScrollView style={[styles.container, { backgroundColor: bgColor }]} contentInsetAdjustmentBehavior='automatic'>
<View style={styles.content}>
<View
style={[
@@ -109,8 +109,7 @@ export default function SettingsScreen() {
>
Information
</Text>
<SettingItem label="Username" value={user?.username || 'Admin'} />
<SettingItem label="Email" value={user?.email || 'N/A'} />
<SettingItem label={user?.email ? 'Email' : 'Username'} value={user?.email || user?.username} />
<SettingItem label="Server URL" value={serverAddress || 'N/A'} />
<SettingItem label="App Version" value={`${Constants.expoConfig?.version}`} />
</View>
@@ -137,7 +136,7 @@ export default function SettingsScreen() {
<View style={styles.footer}>
<Text style={[styles.footerText, { color: subTextColor }]}>
UpSnap Mobile App
Jumpstart
</Text>
<Text style={[styles.footerText, { color: subTextColor }]}>
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 { useEffect } from 'react';
import { Text, TouchableOpacity } from 'react-native';
import React, { useEffect } from 'react';
import { useColorScheme } from '../hooks/use-color-scheme';
import { AuthProvider, useAuth } from '../src/context/AuthContext';
function AuthRedirect() {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
const segments = useSegments();
const { isAuthenticated, isLoading } = useAuth();
const firstSegment = segments[0];
useEffect(() => {
if (isLoading) {
return;
}
const isLoginRoute = segments[0] === 'login';
const isLoginRoute = firstSegment === 'login';
if (!isAuthenticated && !isLoginRoute) {
router.replace('/login');
} else if (isAuthenticated && isLoginRoute) {
router.replace('/devices');
}
if (isAuthenticated && isLoginRoute) {
router.replace('/(tabs)');
}
}, [isAuthenticated, isLoading, router, segments]);
}, [firstSegment, isAuthenticated, isLoading, router]);
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() {
const isDark = useColorScheme() === 'dark';
const bgColor = isDark ? '#0b0b0d' : '#fff';
const titleColor = isDark ? '#fff' : '#000';
const segments = useSegments();
const last = segments[segments.length - 1];
const isIndex = last === '(tabs)' || last === undefined;
return (
<AuthProvider>
<AuthRedirect />
<Stack>
{/* Root index that performs auth redirect (app/index.tsx) */}
<Stack.Screen name="index" options={{ headerShown: false }} />
{/* Tabs parent - render tabs with dynamic header */}
<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 screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="login" />
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="scan-devices"
options={{
headerShown: true,
headerTitle: () => <TabsTitle title="Scan Devices" />,
title: 'Scan for Devices',
headerStyle: { backgroundColor: bgColor },
headerTintColor: titleColor,
headerBackButtonDisplayMode: 'minimal',
}}
/>
{/* Deep link action handler - no header, immediately redirects */}
<Stack.Screen
name="action/[action]/[deviceId]"
options={{ headerShown: false, animation: 'none' }}
options={{ animation: 'none' }}
/>
</Stack>
</AuthProvider>
+2 -2
View File
@@ -20,11 +20,11 @@ export default function ActionHandler() {
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(
CommonActions.reset({
index: 0,
routes: [{ name: '(tabs)' }],
routes: [{ name: 'devices' }],
})
);
}, [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 React, { useEffect } from "react";
import { useAuth } from "../src/context/AuthContext";
export default function IndexRedirect() {
const router = useRouter();
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading) {
if (isAuthenticated) {
router.replace("/(tabs)");
} else {
router.replace("/login");
}
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
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 }]}>
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>
{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": {
"@bacons/apple-targets": "^3.0.6",
"@expo/ui": "55.0.1",
"@expo/ui": "~55.0.15",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"burnt": "^0.13.0",
"expo": "^55.0.5",
"expo": "^55.0.23",
"expo-constants": "~55.0.7",
"expo-font": "~55.0.4",
"expo-glass-effect": "~55.0.7",
"expo-haptics": "~55.0.8",
"expo-image": "~55.0.6",
"expo-linking": "~55.0.7",
"expo-router": "~55.0.4",
"expo-splash-screen": "~55.0.10",
"expo-status-bar": "~55.0.4",
"expo-symbols": "~55.0.5",
"expo-system-ui": "~55.0.9",
"expo-web-browser": "~55.0.9",
"expo-glass-effect": "~55.0.11",
"expo-haptics": "~55.0.14",
"expo-image": "~55.0.10",
"expo-linking": "~55.0.15",
"expo-router": "~55.0.14",
"expo-splash-screen": "~55.0.20",
"expo-status-bar": "~55.0.6",
"expo-symbols": "~55.0.8",
"expo-system-ui": "~55.0.17",
"expo-web-browser": "~55.0.15",
"react": "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-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.7.2"
"react-native-worklets": "0.7.4"
},
"devDependencies": {
"@types/react": "~19.2.10",
+4
View File
@@ -13,6 +13,8 @@ interface WidgetDevice {
name: string;
mac: string;
ip: string;
canSleep: boolean;
canShutdown: boolean;
status: string;
}
@@ -32,6 +34,8 @@ export function syncDevicesToWidget(devices: Device[]): void {
name: device.name,
mac: device.mac,
ip: device.ip,
canSleep: device.sol_enabled,
canShutdown: device.shutdown_cmd !== "",
status: device.status || 'unknown',
}));
+4 -2
View File
@@ -7,8 +7,10 @@ export interface Device {
ip: string;
netmask: string;
broadcast: string;
secureOnPassword: string;
port: number;
password: string;
shutdown_cmd: string;
sol_enabled: boolean;
ports: number[];
groups: string[];
status: string;
created: string;
+2
View File
@@ -32,6 +32,8 @@ struct DeviceInfo: Codable, Hashable, Identifiable {
let name: String
let mac: String
let ip: String
let canSleep: Bool
let canShutdown: Bool
let status: String
}
+20 -14
View File
@@ -16,7 +16,7 @@ struct Provider: AppIntentTimelineProvider {
DeviceEntry(
date: Date(),
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
VStack(spacing: 6) {
HStack(spacing: 6) {
ActionButton(action: .wake, deviceId: device.id)
ActionButton(action: .sleep, deviceId: device.id)
ActionButton(action: .wake, deviceId: device.id, enabled: device.status == "offline")
ActionButton(action: .sleep, deviceId: device.id, enabled: device.status == "online" && device.canSleep)
}
HStack(spacing: 6) {
ActionButton(action: .restart, deviceId: device.id)
ActionButton(action: .shutdown, deviceId: device.id)
ActionButton(action: .restart, deviceId: device.id, enabled: device.status == "online" && device.canShutdown)
ActionButton(action: .shutdown, deviceId: device.id, enabled: device.status == "online" && device.canShutdown)
}
}
} else {
// Medium/Large widget: horizontal layout
HStack(spacing: 8) {
ActionButton(action: .wake, deviceId: device.id)
ActionButton(action: .sleep, deviceId: device.id)
ActionButton(action: .restart, deviceId: device.id)
ActionButton(action: .shutdown, deviceId: device.id)
ActionButton(action: .wake, deviceId: device.id, enabled: device.status == "offline")
ActionButton(action: .sleep, deviceId: device.id, enabled: device.status == "online" && device.canSleep)
ActionButton(action: .restart, deviceId: device.id, enabled: device.status == "online" && device.canShutdown)
ActionButton(action: .shutdown, deviceId: device.id, enabled: device.status == "online" && device.canShutdown)
}
}
}
@@ -118,6 +118,7 @@ struct DeviceWidgetEntryView: View {
struct ActionButton: View {
let action: DeviceAction
let deviceId: String
let enabled: Bool
var icon: String {
switch action {
@@ -137,6 +138,10 @@ struct ActionButton: View {
}
}
var disabled: Color {
return .gray
}
var label: String {
switch action {
case .wake: return "Wake"
@@ -147,15 +152,16 @@ struct ActionButton: View {
}
var body: some View {
Link(destination: action.url(for: deviceId)) {
Link(destination: enabled ? action.url(for: deviceId) : URL("")!) {
VStack(spacing: 2) {
Image(systemName: icon)
.font(.system(size: 16, weight: .medium))
.foregroundColor(color)
.foregroundColor(enabled ? color : disabled)
}
.frame(maxWidth: .infinity, minHeight: 36)
.background(color.opacity(0.15))
.background((enabled ? color : disabled).opacity(0.15))
.cornerRadius(8)
.disabled(!enabled)
}
}
}
@@ -187,7 +193,7 @@ typealias widget = DeviceControlWidget
DeviceEntry(
date: .now,
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(
date: .now,
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")
)
}