Compare commits

...
2 Commits
11 changed files with 3786 additions and 4862 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
import { NativeTabs } from 'expo-router/unstable-native-tabs'; import { NativeTabs } from 'expo-router/native-tabs';
export default function TabLayout() { export default function TabLayout() {
return ( return (
+28 -18
View File
@@ -1,4 +1,3 @@
import { 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';
@@ -19,6 +18,7 @@ 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'; import { useAuth } from '@/src/context/AuthContext';
import { DeviceContextMenu } from '@/src/components/DeviceContextMenu';
const isAuthError = (error: unknown) => const isAuthError = (error: unknown) =>
typeof error === 'object' && typeof error === 'object' &&
@@ -43,7 +43,6 @@ export default function DeviceListScreen() {
const fetchDevices = useCallback(async (showLoading = false) => { const fetchDevices = useCallback(async (showLoading = false) => {
try { try {
if (showLoading) setIsLoading(true);
const data = await api.getDevices(); const data = await api.getDevices();
setDevices(data); setDevices(data);
// Sync devices to iOS widget // Sync devices to iOS widget
@@ -66,8 +65,31 @@ export default function DeviceListScreen() {
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
fetchDevices(true); let cancelled = false;
}, [fetchDevices, isAuthenticated]); void api.getDevices().then(
data => {
if (cancelled) return;
setDevices(data);
syncDevicesToWidget(data);
setIsLoading(false);
},
(error: unknown) => {
if (cancelled) return;
if (isAuthError(error)) {
setDevices([]);
} else {
const message =
error instanceof Error ? error.message : 'Failed to load devices';
Alert.alert('Error', message);
}
setIsLoading(false);
}
);
return () => {
cancelled = true;
};
}, [isAuthenticated]);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
@@ -301,17 +323,7 @@ export default function DeviceListScreen() {
const isOffline = item.status?.toLowerCase() === 'offline'; const isOffline = item.status?.toLowerCase() === 'offline';
return ( return (
<Host> <DeviceContextMenu onDelete={() => handleDelete(item)}>
<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,
@@ -392,9 +404,7 @@ export default function DeviceListScreen() {
/> />
</View> </View>
</View> </View>
</ContextMenu.Trigger> </DeviceContextMenu>
</ContextMenu>
</Host>
); );
}; };
+81 -54
View File
@@ -12,6 +12,60 @@ 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';
function SettingItem({
label,
value,
onPress,
textColor,
subTextColor,
borderBottomColor,
}: {
label: string;
value?: string;
onPress?: () => void;
textColor: string;
subTextColor: string;
borderBottomColor: string;
}) {
return (
<TouchableOpacity
style={[styles.settingItem, { borderBottomColor }]}
onPress={onPress}
disabled={!onPress}
activeOpacity={onPress ? 0.7 : 1}
>
<Text style={[styles.settingLabel, { color: textColor }]}>{label}</Text>
<View style={styles.settingValueContainer}>
<Text
style={[styles.settingValue, { color: subTextColor }]}
numberOfLines={1}
>
{value}
</Text>
</View>
</TouchableOpacity>
);
}
function ActionButton({
title,
onPress,
backgroundColor,
}: {
title: string;
onPress: () => void;
backgroundColor: string;
}) {
return (
<TouchableOpacity
style={[styles.actionButton, { backgroundColor }]}
onPress={onPress}
>
<Text style={styles.actionButtonText}>{title}</Text>
</TouchableOpacity>
);
}
export default function SettingsScreen() { export default function SettingsScreen() {
const { user, serverAddress, logout } = useAuth(); const { user, serverAddress, logout } = useAuth();
const router = useRouter(); const router = useRouter();
@@ -22,7 +76,6 @@ export default function SettingsScreen() {
const sectionTitleBg = isDark ? '#111111' : '#f9f9f9'; const sectionTitleBg = isDark ? '#111111' : '#f9f9f9';
const textColor = isDark ? '#fff' : '#333'; const textColor = isDark ? '#fff' : '#333';
const subTextColor = isDark ? '#c6c6c8' : '#666'; const subTextColor = isDark ? '#c6c6c8' : '#666';
const primary = isDark ? '#0A84FF' : '#007AFF';
const destructiveColor = '#f44336'; const destructiveColor = '#f44336';
const handleLogout = () => { const handleLogout = () => {
@@ -39,55 +92,7 @@ export default function SettingsScreen() {
]); ]);
}; };
const SettingItem = ({ const borderBottomColor = isDark ? 'rgba(255,255,255,0.03)' : '#f0f0f0';
label,
value,
onPress,
}: {
label: string;
value?: string;
onPress?: () => void;
}) => (
<TouchableOpacity
style={[
styles.settingItem,
{ borderBottomColor: isDark ? 'rgba(255,255,255,0.03)' : '#f0f0f0' },
]}
onPress={onPress}
disabled={!onPress}
activeOpacity={onPress ? 0.7 : 1}
>
<Text style={[styles.settingLabel, { color: textColor }]}>{label}</Text>
<View style={styles.settingValueContainer}>
<Text
style={[styles.settingValue, { color: subTextColor }]}
numberOfLines={1}
>
{value}
</Text>
</View>
</TouchableOpacity>
);
const ActionButton = ({
title,
onPress,
destructive = false,
}: {
title: string;
onPress: () => void;
destructive?: boolean;
}) => (
<TouchableOpacity
style={[
styles.actionButton,
{ backgroundColor: destructive ? destructiveColor : primary },
]}
onPress={onPress}
>
<Text style={styles.actionButtonText}>{title}</Text>
</TouchableOpacity>
);
return ( return (
<ScrollView style={[styles.container, { backgroundColor: bgColor }]} contentInsetAdjustmentBehavior='automatic'> <ScrollView style={[styles.container, { backgroundColor: bgColor }]} contentInsetAdjustmentBehavior='automatic'>
@@ -109,9 +114,27 @@ export default function SettingsScreen() {
> >
Information Information
</Text> </Text>
<SettingItem label={user?.email ? 'Email' : 'Username'} value={user?.email || user?.username} /> <SettingItem
<SettingItem label="Server URL" value={serverAddress || 'N/A'} /> label={user?.email ? 'Email' : 'Username'}
<SettingItem label="App Version" value={`${Constants.expoConfig?.version}`} /> value={user?.email || user?.username}
textColor={textColor}
subTextColor={subTextColor}
borderBottomColor={borderBottomColor}
/>
<SettingItem
label="Server URL"
value={serverAddress || 'N/A'}
textColor={textColor}
subTextColor={subTextColor}
borderBottomColor={borderBottomColor}
/>
<SettingItem
label="App Version"
value={`${Constants.expoConfig?.version}`}
textColor={textColor}
subTextColor={subTextColor}
borderBottomColor={borderBottomColor}
/>
</View> </View>
<View <View
@@ -131,7 +154,11 @@ export default function SettingsScreen() {
> >
Actions Actions
</Text> </Text>
<ActionButton title="Logout" onPress={handleLogout} destructive /> <ActionButton
title="Logout"
onPress={handleLogout}
backgroundColor={destructiveColor}
/>
</View> </View>
<View style={styles.footer}> <View style={styles.footer}>
+2 -2
View File
@@ -1,6 +1,6 @@
import { CommonActions, useNavigation } from '@react-navigation/native';
import * as Burnt from 'burnt'; import * as Burnt from 'burnt';
import { Redirect, useLocalSearchParams } from 'expo-router'; import { Redirect, useLocalSearchParams, useNavigation } from 'expo-router';
import { CommonActions } from 'expo-router/react-navigation';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import api from '../../../src/services/api'; import api from '../../../src/services/api';
+2 -2
View File
@@ -56,8 +56,8 @@ export default function ScanDevicesScreen() {
ip: deviceIP, ip: deviceIP,
netmask: "255.255.255.0", netmask: "255.255.255.0",
broadcast: "", broadcast: "",
secureOnPassword: "", password: "",
port: 9, ports: [9],
groups: [], groups: [],
status: "offline", status: "offline",
}); });
-21
View File
@@ -1,21 +0,0 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
+3572 -4706
View File
File diff suppressed because it is too large. Load diff
+27 -30
View File
@@ -12,42 +12,39 @@
}, },
"dependencies": { "dependencies": {
"@bacons/apple-targets": "^3.0.6", "@bacons/apple-targets": "^3.0.6",
"@expo/ui": "~55.0.15", "@expo/ui": "~58.0.21",
"@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/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"burnt": "^0.13.0", "burnt": "^0.13.0",
"expo": "^55.0.23", "expo": "^58.0.16",
"expo-constants": "~55.0.7", "expo-constants": "~58.0.12",
"expo-dev-client": "~55.0.33", "expo-dev-client": "~58.0.14",
"expo-font": "~55.0.4", "expo-font": "~58.0.9",
"expo-glass-effect": "~55.0.11", "expo-glass-effect": "~58.0.4",
"expo-haptics": "~55.0.14", "expo-haptics": "~58.0.7",
"expo-image": "~55.0.10", "expo-image": "~58.0.16",
"expo-linking": "~55.0.15", "expo-linking": "~58.0.13",
"expo-router": "~55.0.14", "expo-router": "~58.0.24",
"expo-splash-screen": "~55.0.20", "expo-splash-screen": "~58.0.10",
"expo-status-bar": "~55.0.6", "expo-status-bar": "~58.0.3",
"expo-symbols": "~55.0.8", "expo-symbols": "~58.0.4",
"expo-system-ui": "~55.0.17", "expo-system-ui": "~58.0.6",
"expo-web-browser": "~55.0.15", "expo-web-browser": "~58.0.6",
"react": "19.2.0", "react": "19.3.0",
"react-dom": "19.2.0", "react-dom": "19.3.0",
"react-native": "0.83.6", "react-native": "0.88.0",
"react-native-gesture-handler": "~2.30.0", "react-native-gesture-handler": "~3.3.0",
"react-native-reanimated": "4.2.1", "react-native-reanimated": "4.7.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.10.1",
"react-native-screens": "~4.23.0", "react-native-screens": "~4.30.0",
"react-native-web": "~0.21.0", "react-native-web": "^0.21.4",
"react-native-worklets": "0.7.4" "react-native-worklets": "0.13.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.10", "@types/react": "~19.3.0",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-expo": "~55.0.0", "eslint-config-expo": "~58.0.4",
"typescript": "~5.9.2" "typescript": "~6.0.3"
}, },
"private": true "private": true
} }
+26
View File
@@ -0,0 +1,26 @@
import { Button, ContextMenu, Host } from '@expo/ui/swift-ui';
import React from 'react';
export function DeviceContextMenu({
children,
onDelete,
}: {
children: React.ReactNode;
onDelete: () => void;
}) {
return (
<Host>
<ContextMenu>
<ContextMenu.Items>
<Button
systemImage="trash"
role="destructive"
label="Delete Device"
onPress={onDelete}
/>
</ContextMenu.Items>
<ContextMenu.Trigger>{children}</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
export function DeviceContextMenu({
children,
}: {
children: React.ReactNode;
onDelete: () => void;
}) {
return <>{children}</>;
}
+37 -28
View File
@@ -23,10 +23,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [canCreate, setCanCreate] = useState<boolean | null>(null); const [canCreate, setCanCreate] = useState<boolean | null>(null);
useEffect(() => {
loadAuth();
}, []);
const clearSession = useCallback(async () => { const clearSession = useCallback(async () => {
await AsyncStorage.multiRemove(['auth_token', 'auth_user', 'auth_can_create']); await AsyncStorage.multiRemove(['auth_token', 'auth_user', 'auth_can_create']);
@@ -45,33 +41,46 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}; };
}, [clearSession]); }, [clearSession]);
const loadAuth = async () => { useEffect(() => {
try { let cancelled = false;
const storedToken = await AsyncStorage.getItem('auth_token');
const storedUser = await AsyncStorage.getItem('auth_user');
const storedServerAddress = await AsyncStorage.getItem('auth_server_address');
const storedCanCreate = await AsyncStorage.getItem('auth_can_create');
if (storedCanCreate) { void AsyncStorage.multiGet([
setCanCreate(storedCanCreate === 'true'); 'auth_token',
} 'auth_user',
'auth_server_address',
if (storedToken && storedUser) { 'auth_can_create',
setToken(storedToken); ]).then(
api.setToken(storedToken); ([[, storedToken], [, storedUser], [, storedServerAddress], [, storedCanCreate]]) => {
setUser(JSON.parse(storedUser)); if (cancelled) return;
}
if (storedServerAddress) { if (storedCanCreate) {
setServerAddress(storedServerAddress); setCanCreate(storedCanCreate === 'true');
api.setAddress(storedServerAddress + '/api'); }
if (storedToken && storedUser) {
setToken(storedToken);
api.setToken(storedToken);
setUser(JSON.parse(storedUser));
}
if (storedServerAddress) {
setServerAddress(storedServerAddress);
api.setAddress(storedServerAddress + '/api');
}
setIsLoading(false);
},
error => {
if (cancelled) return;
console.error('Failed to load auth', error);
setIsLoading(false);
} }
} catch (error) { );
console.error('Failed to load auth', error);
} finally { return () => {
setIsLoading(false); cancelled = true;
} };
}; }, []);
const login = async (serverAddress: string, identity: string, password: string) => { const login = async (serverAddress: string, identity: string, password: string) => {
try { try {