feat: better theming, UI fixes, bug fixes
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,9 +299,19 @@ 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 (
|
||||||
|
<Host>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenu.Items>
|
||||||
|
<Button
|
||||||
|
systemImage="trash"
|
||||||
|
role="destructive"
|
||||||
|
label="Delete Device"
|
||||||
|
onPress={() => handleDelete(item)}
|
||||||
|
/>
|
||||||
|
</ContextMenu.Items>
|
||||||
|
<ContextMenu.Trigger>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.deviceCard,
|
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.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}>
|
||||||
{isOffline && (
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
name="Wake"
|
name="Wake"
|
||||||
|
enabled={isOffline}
|
||||||
symbolName="bolt.circle.fill"
|
symbolName="bolt.circle.fill"
|
||||||
fallbackName="flash"
|
fallbackName="flash"
|
||||||
color="#4CAF50"
|
color={isOffline ? '#4CAF50' : subTextColor}
|
||||||
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>
|
</View>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
</ContextMenu>
|
||||||
|
</Host>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -406,17 +407,20 @@ 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}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={isDark ? subTextColor : undefined}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
ListFooterComponent={<View style={{ height: 20 }} />}
|
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
<Text style={[styles.emptyText, { color: subTextColor }]}>
|
<Text style={[styles.emptyText, { color: subTextColor }]}>
|
||||||
@@ -425,7 +429,6 @@ export default function DeviceListScreen() {
|
|||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
|
||||||
if (isAuthenticated) {
|
|
||||||
router.replace("/(tabs)");
|
|
||||||
} else {
|
|
||||||
router.replace("/login");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, isLoading, router]);
|
|
||||||
|
|
||||||
return <StatusBar style="auto" />;
|
return <StatusBar style="auto" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Redirect href={isAuthenticated ? "/devices" : "/login"} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Generated
+1232
-596
File diff suppressed because it is too large
Load Diff
+14
-14
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user