diff --git a/README.md b/README.md index 48dd63f..76b1807 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,200 @@ -# Welcome to your Expo app 👋 +# UpSnap Mobile -This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). +A React Native Expo app that connects to an UpSnap server and provides mobile access to all Wake-on-LAN features. -## Get started +## Features -1. Install dependencies +- 🔌 **Device Management**: View, add, edit, and delete devices +- 💤 **Power Control**: Wake, sleep, reboot, and shutdown devices remotely +- 🔍 **Network Scanning**: Discover devices on your local network +- 👤 **Authentication**: Secure login with username/email and password +- 📱 **Mobile-First UI**: Touch-optimized interface designed for phones +- 🎨 **Clean Design**: Modern, intuitive interface with visual status indicators - ```bash - npm install - ``` +## Setup -2. Start the app +### Prerequisites - ```bash - npx expo start - ``` +- Node.js installed +- Expo CLI installed (`npm install -g expo-cli`) +- An UpSnap server instance running (e.g., https://wol.f6knight.duckdns.org/) -In the output, you'll find options to open the app in a - -- [development build](https://docs.expo.dev/develop/development-builds/introduction/) -- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) -- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) -- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo - -You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). - -## Get a fresh project - -When you're ready, run: +### Installation +1. Install dependencies: ```bash -npm run reset-project +npm install ``` -This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. +2. Start the development server: +```bash +npm start +``` -## Learn more +3. Run on your preferred platform: + - iOS: Press `i` in the terminal or run `npm run ios` + - Android: Press `a` in the terminal or run `npm run android` + - Web: Press `w` in the terminal or run `npm run web` -To learn more about developing your project with Expo, look at the following resources: +## Usage -- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). -- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. +### First Time Setup -## Join the community +1. Open the app +2. Enter your UpSnap server credentials: + - Username or Email + - Password + - Check "Login as Admin" if you're an admin user +3. Tap "Login" -Join our community of developers creating universal apps. +### Device Management -- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. -- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. +#### Adding a Device + +1. Tap the "+ Add" button in the Devices screen +2. Fill in the required fields: + - Device Name (e.g., "My PC") + - MAC Address (format: XX:XX:XX:XX:XX:XX) + - IP Address + - Optional: Netmask, Broadcast Address, SecureOn Password, Port +3. Tap "Add Device" + +#### Scanning for Devices + +1. Tap the "Scan" button in the Devices screen +2. Wait for the network scan to complete +3. Tap on a discovered device to add it to your list + +#### Managing Devices + +- **View Devices**: Scroll through the list on the main screen +- **Device Details**: Tap on a device card to view and edit details +- **Wake Device**: Tap the green "Wake" button on a device card +- **Sleep Device**: Tap the orange "Sleep" button +- **Reboot Device**: Tap the blue "Reboot" button +- **Shutdown Device**: Tap the red "Shutdown" button + +### Status Indicators + +- 🟢 Green dot: Device is online +- 🔴 Red dot: Device is offline +- 🟠 Orange dot: Status unknown + +## Screens + +### Login Screen +- Username/email and password fields +- Admin login option +- Persistent authentication using AsyncStorage + +### Devices Screen +- List of all devices with status indicators +- Quick action buttons for each device +- Pull-to-refresh for updating status +- Add device and scan network buttons + +### Device Details Screen +- View all device information +- Edit device details +- Delete device option + +### Add Device Screen +- Manual device entry form +- All required and optional fields +- Input validation + +### Scan Devices Screen +- Network scanning functionality +- List of discovered devices +- Quick add to device list + +### Settings Screen +- User information display +- Server configuration +- Logout option +- Clear data option + +## API Integration + +The app connects to an UpSnap server via its REST API: + +- **Authentication**: `/api/collections/users/auth-with-password` or `/api/collections/_superusers/auth-with-password` +- **Devices**: `/api/collections/devices/records` +- **Wake**: `/api/upsnap/wake/:id` +- **Sleep**: `/api/collections/upsnap/sleep/:id` +- **Reboot**: `/api/upsnap/reboot/:id` +- **Shutdown**: `/api/upsnap/shutdown/:id` +- **Scan**: `/api/upsnap/scan` + +## Project Structure + +``` +src/ +├── components/ # Reusable components +├── context/ # React context providers (AuthContext) +├── navigation/ # Navigation setup +├── screens/ # All app screens +│ ├── LoginScreen.tsx +│ ├── DeviceListScreen.tsx +│ ├── DeviceDetailsScreen.tsx +│ ├── AddDeviceScreen.tsx +│ ├── ScanDevicesScreen.tsx +│ └── SettingsScreen.tsx +├── services/ # API service +│ └── api.ts +└── types/ # TypeScript type definitions + └── index.ts +``` + +## Tech Stack + +- **React Native**: Cross-platform mobile framework +- **Expo**: Development platform +- **TypeScript**: Type safety +- **React Navigation**: Navigation library +- **AsyncStorage**: Local data persistence + +## Server Configuration + +The app is pre-configured to connect to: +- Server URL: `https://wol.f6knight.duckdns.org` +- API Base: `/api` + +To change the server URL, modify the `API_BASE_URL` constant in `src/services/api.ts`. + +## Security Notes + +- Authentication tokens are stored securely in AsyncStorage +- All API calls require authentication +- Destructive actions (sleep, reboot, shutdown) require confirmation +- Never expose your UpSnap server to the open web without proper security measures + +## Troubleshooting + +### Login Issues + +- Verify your username/email and password are correct +- Check if you need to log in as admin +- Ensure your UpSnap server is accessible + +### Network Scan Issues + +- Network scanning requires the server to have `nmap` installed +- Scanning may take several minutes to complete +- Make sure your device and server are on the same network + +### Device Control Issues + +- Verify the device's MAC address is correct +- Ensure Wake-on-LAN is enabled in the device's BIOS/UEFI +- Check that the device is on the same network as the server +- For sleep/shutdown/reboot, ensure the device has the required agent installed + +## License + +This project is a mobile companion to UpSnap, which is licensed under the MIT License. + +## Credits + +- UpSnap: https://github.com/seriousm4x/UpSnap +- Built with React Native and Expo diff --git a/app.json b/app.json index e1c9193..fca5712 100644 --- a/app.json +++ b/app.json @@ -9,7 +9,8 @@ "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { - "supportsTablet": true + "supportsTablet": true, + "bundleIdentifier": "com.anonymous.remote-wol" }, "android": { "adaptiveIcon": { diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 54e11d0..4ee0119 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,35 +1,45 @@ -import { Tabs } from 'expo-router'; -import React from 'react'; +import React from "react"; +import { Ionicons } from "@expo/vector-icons"; +import { useRouter } from "expo-router"; +import { View, TouchableOpacity, StyleSheet } from "react-native"; +import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs"; -import { HapticTab } from '@/components/haptic-tab'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; +export function DevicesHeader() { + const router = useRouter(); -export default function TabLayout() { - const colorScheme = useColorScheme(); - - return ( - - , - }} - /> - , - }} - /> - - ); + return ( + + router.push("/scan-devices")} + style={styles.headerButton} + > + + + + ); } + +export default function TabsLayout() { + return ( + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + headerRight: { + flexDirection: "row", + gap: 16, + }, + headerButton: { + paddingHorizontal: 8, + }, +}); diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx deleted file mode 100644 index 71518f9..0000000 --- a/app/(tabs)/explore.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; - -import { Collapsible } from '@/components/ui/collapsible'; -import { ExternalLink } from '@/components/external-link'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Fonts } from '@/constants/theme'; - -export default function TabTwoScreen() { - return ( - - }> - - - Explore - - - This app includes example code to help you get started. - - - This app has two screens:{' '} - app/(tabs)/index.tsx and{' '} - app/(tabs)/explore.tsx - - - The layout file in app/(tabs)/_layout.tsx{' '} - sets up the tab navigator. - - - Learn more - - - - - You can open this project on Android, iOS, and the web. To open the web version, press{' '} - w in the terminal running this project. - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for - different screen densities - - - - Learn more - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect - what the user's current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - This template includes an example of an animated component. The{' '} - components/HelloWave.tsx component uses - the powerful{' '} - - react-native-reanimated - {' '} - library to create a waving hand animation. - - {Platform.select({ - ios: ( - - The components/ParallaxScrollView.tsx{' '} - component provides a parallax effect for the header image. - - ), - })} - - - ); -} - -const styles = StyleSheet.create({ - headerImage: { - color: '#808080', - bottom: -90, - left: -35, - position: 'absolute', - }, - titleContainer: { - flexDirection: 'row', - gap: 8, - }, -}); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 786b736..222a990 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,98 +1,346 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; +import React, { useState, useEffect } from "react"; +import { + View, + Text, + FlatList, + TouchableOpacity, + StyleSheet, + RefreshControl, + Alert, + ActivityIndicator, +} from "react-native"; +import { ContextMenu, Button as SwiftUIButton, Host } from "@expo/ui/swift-ui"; +import { useRouter, useLocalSearchParams, Stack } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import api from "../../src/services/api"; +import { Device } from "../../src/types"; -import { HelloWave } from '@/components/hello-wave'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { Link } from 'expo-router'; +function DevicesHeader() { + const router = useRouter(); -export default function HomeScreen() { - return ( - - }> - - Welcome! - - - - Step 1: Try it - - Edit app/(tabs)/index.tsx to see changes. - Press{' '} - - {Platform.select({ - ios: 'cmd + d', - android: 'cmd + m', - web: 'F12', - })} - {' '} - to open developer tools. - - - - - - Step 2: Explore - - - - alert('Action pressed')} /> - alert('Share pressed')} - /> - - alert('Delete pressed')} - /> - - - + return ( + + router.push("/scan-devices")}> + + + + ); +} - - {`Tap the Explore tab to learn more about what's included in this starter app.`} - - - - Step 3: Get a fresh start - - {`When you're ready, run `} - npm run reset-project to get a fresh{' '} - app directory. This will move the current{' '} - app to{' '} - app-example. - - - - ); +export default function DeviceListScreen() { + const router = useRouter(); + const [devices, setDevices] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + useEffect(() => { + loadDevices(); + }, []); + + const loadDevices = async () => { + try { + setIsLoading(true); + const data = await api.getDevices(); + setDevices(data); + } catch (error: any) { + Alert.alert("Error", error.message || "Failed to load devices"); + } finally { + setIsLoading(false); + } + }; + + const onRefresh = async () => { + setRefreshing(true); + await loadDevices(); + setRefreshing(false); + }; + + const handleWake = async (device: Device) => { + try { + await api.wakeDevice(device.id); + Alert.alert("Success", `Wake signal sent to ${device.name}`); + } catch (error: any) { + Alert.alert("Error", error.message || "Failed to wake device"); + } + }; + + const handleSleep = async (device: Device) => { + Alert.alert("Confirm", `Send ${device.name} to sleep?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Sleep", + style: "destructive", + onPress: async () => { + try { + await api.sleepDevice(device.id); + Alert.alert("Success", `Sleep signal sent to ${device.name}`); + } catch (error: any) { + Alert.alert("Error", error.message || "Failed to sleep device"); + } + }, + }, + ]); + }; + + const handleReboot = async (device: Device) => { + Alert.alert("Confirm", `Reboot ${device.name}?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Reboot", + style: "destructive", + onPress: async () => { + try { + await api.rebootDevice(device.id); + Alert.alert("Success", `Reboot signal sent to ${device.name}`); + } catch (error: any) { + Alert.alert("Error", error.message || "Failed to reboot device"); + } + }, + }, + ]); + }; + + const handleShutdown = async (device: Device) => { + Alert.alert( + "Confirm Shutdown", + `Shutdown ${device.name}? This cannot be undone.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Shutdown", + style: "destructive", + onPress: async () => { + try { + await api.shutdownDevice(device.id); + Alert.alert("Success", `Shutdown signal sent to ${device.name}`); + } catch (error: any) { + Alert.alert( + "Error", + error.message || "Failed to shutdown device" + ); + } + }, + }, + ] + ); + }; + + const handleDelete = (device: Device) => { + Alert.alert("Delete Device", `Delete "${device.name}"?`, [ + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + await api.deleteDevice(device.id); + Alert.alert("Success", "Device deleted successfully"); + loadDevices(); + } catch (error: any) { + Alert.alert("Error", error.message || "Failed to delete device"); + } + }, + }, + ]); + }; + + const getStatusColor = (status: string) => { + switch (status?.toLowerCase()) { + case "online": + return "#4CAF50"; + case "offline": + return "#f44336"; + default: + return "#ff9800"; + } + }; + + const renderDevice = ({ item }: { item: Device }) => ( + + + + handleDelete(item)} + > + Delete Device + + + + + router.push(`/devices/${item.id}`)} + activeOpacity={1} + > + + + {item.name} + {item.ip} + + + + + + handleWake(item)} + > + Wake + + handleSleep(item)} + > + Sleep + + handleReboot(item)} + > + Reboot + + handleShutdown(item)} + > + Shutdown + + + + + + + + ); + + if (isLoading) { + return ( + + + + ); + } + + return ( + + , + }} + /> + item.id} + contentContainerStyle={styles.list} + refreshControl={ + + } + ListEmptyComponent={ + + No devices found + + } + /> + + ); } const styles = StyleSheet.create({ - titleContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - stepContainer: { - gap: 8, - marginBottom: 8, - }, - reactLogo: { - height: 178, - width: 290, - bottom: 0, - left: 0, - position: 'absolute', - }, + container: { + flex: 1, + backgroundColor: "#f5f5f5", + }, + headerRight: { + flexDirection: "row", + gap: 16, + }, + headerButton: { + paddingHorizontal: 8, + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + list: { + padding: 15, + }, + deviceCard: { + backgroundColor: "#fff", + borderRadius: 12, + padding: 15, + marginBottom: 15, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + deviceHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 12, + }, + deviceInfo: { + flex: 1, + }, + deviceName: { + fontSize: 18, + fontWeight: "bold", + color: "#333", + marginBottom: 4, + }, + deviceIP: { + fontSize: 14, + color: "#666", + }, + statusDot: { + width: 12, + height: 12, + borderRadius: 6, + }, + deviceActions: { + flexDirection: "row", + justifyContent: "space-between", + gap: 8, + }, + actionButton: { + flex: 1, + paddingVertical: 10, + borderRadius: 8, + alignItems: "center", + }, + wakeButton: { + backgroundColor: "#4CAF50", + }, + sleepButton: { + backgroundColor: "#FF9800", + }, + rebootButton: { + backgroundColor: "#2196F3", + }, + shutdownButton: { + backgroundColor: "#f44336", + }, + actionButtonText: { + color: "#fff", + fontWeight: "600", + fontSize: 12, + }, + emptyContainer: { + alignItems: "center", + paddingVertical: 50, + }, + emptyText: { + fontSize: 16, + color: "#999", + }, }); diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx new file mode 100644 index 0000000..d163619 --- /dev/null +++ b/app/(tabs)/settings.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, +} from 'react-native'; +import { useAuth } from '../../src/context/AuthContext'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export default function SettingsScreen() { + const { user, logout } = useAuth(); + + const handleLogout = () => { + Alert.alert( + 'Logout', + 'Are you sure you want to logout?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Logout', + style: 'destructive', + onPress: async () => { + await logout(); + }, + }, + ] + ); + }; + + const handleClearData = () => { + Alert.alert( + 'Clear Data', + 'This will clear all stored data. Are you sure?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear', + style: 'destructive', + onPress: async () => { + try { + await AsyncStorage.clear(); + Alert.alert('Success', 'All data cleared'); + } catch (error) { + Alert.alert('Error', 'Failed to clear data'); + } + }, + }, + ] + ); + }; + + const SettingItem = ({ + label, + value, + onPress + }: { + label: string; + value?: string; + onPress?: () => void; + }) => ( + + {label} + + + {value || 'N/A'} + + + + ); + + const ActionButton = ({ + title, + onPress, + destructive = false, + }: { + title: string; + onPress: () => void; + destructive?: boolean; + }) => ( + + + {title} + + + ); + + return ( + + + + User Information + + + + + + + Server + + + + + + App Info + + + + + + Actions + + + + + + + UpSnap Mobile App + + + Connect to your UpSnap server + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + content: { + padding: 20, + }, + section: { + backgroundColor: '#fff', + borderRadius: 12, + marginBottom: 20, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + sectionTitle: { + fontSize: 14, + fontWeight: 'bold', + color: '#666', + paddingHorizontal: 15, + paddingTop: 15, + paddingBottom: 10, + backgroundColor: '#f9f9f9', + }, + settingItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 15, + paddingVertical: 15, + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + }, + settingLabel: { + fontSize: 16, + color: '#333', + flex: 1, + }, + settingValueContainer: { + flex: 1, + alignItems: 'flex-end', + }, + settingValue: { + fontSize: 14, + color: '#666', + maxWidth: 200, + }, + actionButton: { + backgroundColor: '#007AFF', + margin: 15, + padding: 15, + borderRadius: 8, + alignItems: 'center', + }, + actionButtonDestructive: { + backgroundColor: '#f44336', + }, + actionButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: 'bold', + }, + actionButtonTextDestructive: { + color: '#fff', + }, + footer: { + alignItems: 'center', + paddingVertical: 30, + }, + footerText: { + fontSize: 14, + color: '#999', + marginBottom: 5, + }, +}); diff --git a/app/_layout.tsx b/app/_layout.tsx index f518c9b..09b27ff 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,24 +1,41 @@ -import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; -import { Stack } from 'expo-router'; -import { StatusBar } from 'expo-status-bar'; -import 'react-native-reanimated'; +import { StatusBar } from "expo-status-bar"; +import { AuthProvider, useAuth } from "../src/context/AuthContext"; +import { useEffect } from "react"; +import { Stack, useRouter } from "expo-router"; -import { useColorScheme } from '@/hooks/use-color-scheme'; +function RootStack() { + const router = useRouter(); + const { isAuthenticated, isLoading } = useAuth(); -export const unstable_settings = { - anchor: '(tabs)', -}; + useEffect(() => { + if (!isLoading) { + if (isAuthenticated) { + router.replace('/(tabs)'); + } else { + router.replace('/login'); + } + } + }, [isAuthenticated, isLoading, router]); + + return ( + + {isAuthenticated ? ( + + ) : ( + + )} + + + + + ); +} export default function RootLayout() { - const colorScheme = useColorScheme(); - - return ( - - - - - - - - ); + return ( + + + + + ); } diff --git a/app/add-device.tsx b/app/add-device.tsx new file mode 100644 index 0000000..21c3d75 --- /dev/null +++ b/app/add-device.tsx @@ -0,0 +1,236 @@ +import React, { useState } from 'react'; +import { + View, + Text, + ScrollView, + StyleSheet, + TextInput, + TouchableOpacity, + Alert, + ActivityIndicator, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import api from '../src/services/api'; + +export default function AddDeviceScreen() { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + + const [formData, setFormData] = useState({ + name: '', + mac: '', + ip: '', + netmask: '255.255.255.0', + broadcast: '', + secureOnPassword: '', + port: '9', + }); + + const handleSave = async () => { + if (!formData.name || !formData.mac || !formData.ip) { + Alert.alert('Error', 'Please fill in all required fields'); + return; + } + + setIsLoading(true); + try { + await api.createDevice({ + name: formData.name, + mac: formData.mac, + ip: formData.ip, + netmask: formData.netmask, + broadcast: formData.broadcast, + secureOnPassword: formData.secureOnPassword, + port: parseInt(formData.port) || 9, + groups: [], + status: 'offline', + }); + Alert.alert('Success', 'Device added successfully'); + router.back(); + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to add device'); + } finally { + setIsLoading(false); + } + }; + + return ( + + + Add New Device + + + + Device Name * + setFormData({ ...formData, name: text })} + placeholder="My PC" + autoCapitalize="words" + /> + + + + MAC Address * + setFormData({ ...formData, mac: text })} + placeholder="00:11:22:33:44:55" + autoCapitalize="characters" + /> + + Format: XX:XX:XX:XX:XX:XX + + + + + IP Address * + setFormData({ ...formData, ip: text })} + placeholder="192.168.1.100" + keyboardType="numeric" + /> + + + + Netmask + setFormData({ ...formData, netmask: text })} + placeholder="255.255.255.0" + keyboardType="numeric" + /> + + + + Broadcast Address + setFormData({ ...formData, broadcast: text })} + placeholder="192.168.1.255" + keyboardType="numeric" + /> + + Optional: Auto-calculated if left blank + + + + + SecureOn Password + setFormData({ ...formData, secureOnPassword: text })} + placeholder="Optional password" + secureTextEntry + /> + + Optional: For SecureOn enabled NICs + + + + + Port + setFormData({ ...formData, port: text })} + placeholder="9" + keyboardType="numeric" + /> + + Default: 9 (Standard Wake-on-LAN port) + + + + + router.back()} + disabled={isLoading} + > + Cancel + + + + {isLoading ? 'Adding...' : 'Add Device'} + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + content: { + padding: 20, + }, + header: { + fontSize: 24, + fontWeight: 'bold', + color: '#333', + marginBottom: 20, + textAlign: 'center', + }, + form: { + gap: 20, + }, + inputGroup: { + gap: 8, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#333', + }, + input: { + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 12, + fontSize: 16, + }, + hint: { + fontSize: 12, + color: '#666', + }, + buttonGroup: { + flexDirection: 'row', + gap: 10, + marginTop: 10, + }, + button: { + flex: 1, + paddingVertical: 15, + borderRadius: 8, + alignItems: 'center', + }, + cancelButton: { + backgroundColor: '#999', + }, + saveButton: { + backgroundColor: '#4CAF50', + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: 'bold', + }, +}); diff --git a/app/devices/[id].tsx b/app/devices/[id].tsx new file mode 100644 index 0000000..4e11a41 --- /dev/null +++ b/app/devices/[id].tsx @@ -0,0 +1,350 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + ScrollView, + StyleSheet, + TextInput, + TouchableOpacity, + Alert, + ActivityIndicator, +} from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import api from '../../src/services/api'; +import { Device } from '../../src/types'; + +export default function DeviceDetailsScreen() { + const router = useRouter(); + const { id: deviceId } = useLocalSearchParams<{ id: string }>(); + + const [device, setDevice] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const [formData, setFormData] = useState({ + name: '', + mac: '', + ip: '', + netmask: '', + broadcast: '', + secureOnPassword: '', + port: '', + }); + + useEffect(() => { + loadDevice(); + }, [deviceId]); + + const loadDevice = async () => { + try { + setIsLoading(true); + const data = await api.getDevice(deviceId); + setDevice(data); + setFormData({ + name: data.name, + mac: data.mac, + ip: data.ip, + netmask: data.netmask || '', + broadcast: data.broadcast || '', + secureOnPassword: data.secureOnPassword || '', + port: String(data.port), + }); + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to load device'); + router.back(); + } finally { + setIsLoading(false); + } + }; + + const handleSave = async () => { + if (!formData.name || !formData.mac || !formData.ip) { + Alert.alert('Error', 'Please fill in all required fields'); + return; + } + + setIsSaving(true); + try { + await api.updateDevice(deviceId, { + name: formData.name, + mac: formData.mac, + ip: formData.ip, + netmask: formData.netmask, + broadcast: formData.broadcast, + secureOnPassword: formData.secureOnPassword, + port: parseInt(formData.port) || 9, + }); + setIsEditing(false); + await loadDevice(); + Alert.alert('Success', 'Device updated successfully'); + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to update device'); + } finally { + setIsSaving(false); + } + }; + + const handleDelete = () => { + Alert.alert( + 'Delete Device', + 'Are you sure you want to delete this device?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + await api.deleteDevice(deviceId); + Alert.alert('Success', 'Device deleted successfully'); + router.back(); + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to delete device'); + } + }, + }, + ] + ); + }; + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + {isEditing ? ( + + Device Name * + setFormData({ ...formData, name: text })} + placeholder="Device name" + /> + + MAC Address * + setFormData({ ...formData, mac: text })} + placeholder="00:11:22:33:44:55" + autoCapitalize="characters" + /> + + IP Address * + setFormData({ ...formData, ip: text })} + placeholder="192.168.1.100" + keyboardType="numeric" + /> + + Netmask + setFormData({ ...formData, netmask: text })} + placeholder="255.255.255.0" + keyboardType="numeric" + /> + + Broadcast Address + setFormData({ ...formData, broadcast: text })} + placeholder="192.168.1.255" + keyboardType="numeric" + /> + + SecureOn Password + setFormData({ ...formData, secureOnPassword: text })} + placeholder="Optional password" + /> + + Port + setFormData({ ...formData, port: text })} + placeholder="9" + keyboardType="numeric" + /> + + + + + {isSaving ? 'Saving...' : 'Save'} + + + { + setIsEditing(false); + setFormData({ + name: device!.name, + mac: device!.mac, + ip: device!.ip, + netmask: device!.netmask || '', + broadcast: device!.broadcast || '', + secureOnPassword: device!.secureOnPassword || '', + port: String(device!.port), + }); + }} + > + Cancel + + + + ) : ( + + + Name + {device?.name} + + + MAC Address + {device?.mac} + + + IP Address + {device?.ip} + + + Netmask + {device?.netmask || 'N/A'} + + + Broadcast + {device?.broadcast || 'N/A'} + + + Port + {device?.port} + + + Status + {device?.status} + + + Groups + + {device?.groups?.join(', ') || 'None'} + + + + + setIsEditing(true)} + > + Edit + + + Delete + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + content: { + padding: 20, + }, + form: { + gap: 15, + }, + details: { + gap: 20, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#333', + marginBottom: 5, + }, + input: { + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 12, + fontSize: 16, + }, + detailRow: { + backgroundColor: '#fff', + borderRadius: 8, + padding: 15, + borderWidth: 1, + borderColor: '#e0e0e0', + }, + detailLabel: { + fontSize: 14, + color: '#666', + marginBottom: 4, + }, + detailValue: { + fontSize: 16, + color: '#333', + fontWeight: '500', + }, + buttonGroup: { + flexDirection: 'row', + gap: 10, + marginTop: 20, + }, + button: { + flex: 1, + paddingVertical: 15, + borderRadius: 8, + alignItems: 'center', + }, + saveButton: { + backgroundColor: '#4CAF50', + }, + cancelButton: { + backgroundColor: '#999', + }, + editButton: { + backgroundColor: '#007AFF', + }, + deleteButton: { + backgroundColor: '#f44336', + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: 'bold', + }, +}); diff --git a/app/login.tsx b/app/login.tsx new file mode 100644 index 0000000..ffc1f70 --- /dev/null +++ b/app/login.tsx @@ -0,0 +1,174 @@ +import React, { useState } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, +} from 'react-native'; +import { useAuth } from '../src/context/AuthContext'; +import { useRouter } from 'expo-router'; + +export default function LoginScreen() { + const router = useRouter(); + const [identity, setIdentity] = useState(''); + const [password, setPassword] = useState(''); + const [isSuperuser, setIsSuperuser] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const { login } = useAuth(); + + const handleLogin = async () => { + if (!identity || !password) { + Alert.alert('Error', 'Please enter both identity and password'); + return; + } + + setIsLoading(true); + try { + await login(identity, password, isSuperuser); + router.replace('/'); + } catch (error: any) { + Alert.alert('Login Failed', error.message || 'An error occurred'); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + UpSnap + Wake on LAN Mobile + + + + + + + + setIsSuperuser(!isSuperuser)} + activeOpacity={0.7} + > + + {isSuperuser && } + + Login as Admin + + + + + {isLoading ? 'Logging in...' : 'Login'} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + scrollContent: { + flexGrow: 1, + justifyContent: 'center', + padding: 20, + }, + header: { + alignItems: 'center', + marginBottom: 40, + }, + title: { + fontSize: 32, + fontWeight: 'bold', + color: '#333', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: '#666', + }, + form: { + width: '100%', + }, + input: { + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 15, + fontSize: 16, + marginBottom: 15, + }, + checkboxContainer: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 20, + }, + checkbox: { + width: 24, + height: 24, + borderWidth: 2, + borderColor: '#007AFF', + borderRadius: 4, + marginRight: 10, + justifyContent: 'center', + alignItems: 'center', + }, + checkboxChecked: { + backgroundColor: '#007AFF', + }, + checkmark: { + color: '#fff', + fontSize: 16, + fontWeight: 'bold', + }, + checkboxLabel: { + fontSize: 16, + color: '#333', + }, + button: { + backgroundColor: '#007AFF', + borderRadius: 8, + padding: 15, + alignItems: 'center', + }, + buttonDisabled: { + backgroundColor: '#ccc', + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: 'bold', + }, +}); diff --git a/app/modal.tsx b/app/modal.tsx deleted file mode 100644 index 6dfbc1a..0000000 --- a/app/modal.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Link } from 'expo-router'; -import { StyleSheet } from 'react-native'; - -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; - -export default function ModalScreen() { - return ( - - This is a modal - - Go to home screen - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - padding: 20, - }, - link: { - marginTop: 15, - paddingVertical: 15, - }, -}); diff --git a/app/scan-devices.tsx b/app/scan-devices.tsx new file mode 100644 index 0000000..c0215e5 --- /dev/null +++ b/app/scan-devices.tsx @@ -0,0 +1,433 @@ +import React, { useState } from 'react'; +import { + View, + Text, + FlatList, + TouchableOpacity, + StyleSheet, + Alert, + ActivityIndicator, + Modal, + TextInput, + ScrollView, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import api from '../src/services/api'; +import { NetworkScanResult } from '../src/types'; + +export default function ScanDevicesScreen() { + const router = useRouter(); + const [scanning, setScanning] = useState(false); + const [devices, setDevices] = useState([]); + const [selectedDevice, setSelectedDevice] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + + const [formData, setFormData] = useState({ + name: '', + mac: '', + ip: '', + netmask: '255.255.255.0', + broadcast: '', + secureOnPassword: '', + port: '9', + }); + + const handleScan = async () => { + setScanning(true); + try { + const results = await api.scanNetwork(); + setDevices(results); + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to scan network'); + } finally { + setScanning(false); + } + }; + + const handleAddFromScan = (device: NetworkScanResult) => { + const deviceName = device.name || device.hostname || `Device ${device.ip || device.ip_address}`; + const deviceIP = device.ip || device.ip_address || ''; + const deviceMAC = device.mac || device.mac_address || ''; + const deviceVendor = device.mac_vendor || ''; + + setFormData({ + name: deviceName, + mac: deviceMAC, + ip: deviceIP, + netmask: '255.255.255.0', + broadcast: '', + secureOnPassword: '', + port: '9', + }); + setSelectedDevice(device); + setShowAddModal(true); + }; + + const handleSaveDevice = async () => { + if (!formData.name || !formData.mac || !formData.ip) { + Alert.alert('Error', 'Please fill in all required fields'); + return; + } + + try { + await api.createDevice({ + name: formData.name, + mac: formData.mac, + ip: formData.ip, + netmask: formData.netmask, + broadcast: formData.broadcast, + secureOnPassword: formData.secureOnPassword, + port: parseInt(formData.port) || 9, + groups: [], + status: 'offline', + }); + Alert.alert('Success', 'Device added successfully'); + setShowAddModal(false); + router.back(); + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to add device'); + } + }; + + const renderDevice = ({ item }: { item: NetworkScanResult }) => { + const displayName = item.name || item.hostname || 'Unknown Device'; + const displayIP = item.ip || item.ip_address || ''; + const displayMAC = item.mac || item.mac_address || ''; + const displayVendor = item.mac_vendor || ''; + + return ( + handleAddFromScan(item)} + > + + {displayName} + {displayIP} + {displayMAC} + {displayVendor && displayVendor !== 'Unknown' && ( + {displayVendor} + )} + + + + + + + ); + }; + + return ( + + + Network Scan + + Discover devices on your local network + + + + + {scanning ? ( + + ) : ( + Scan Network + )} + + + + Note: This requires the server to have nmap installed and may take several minutes. + + + {devices.length > 0 && ( + + Discovered Devices ({devices.length}) + `${item.ip || item.ip_address || index}-${index}`} + contentContainerStyle={styles.list} + /> + + )} + + {devices.length === 0 && !scanning && ( + + Tap "Scan Network" to discover devices + + )} + + setShowAddModal(false)} + > + + + + Add Device + setShowAddModal(false)}> + + + + + + + Device Name * + setFormData({ ...formData, name: text })} + placeholder="Device name" + /> + {selectedDevice?.mac_vendor && ( + + Vendor: {selectedDevice.mac_vendor} + + )} + + + + MAC Address * + setFormData({ ...formData, mac: text })} + placeholder="00:11:22:33:44:55" + autoCapitalize="characters" + /> + + + + IP Address * + setFormData({ ...formData, ip: text })} + placeholder="192.168.1.100" + keyboardType="numeric" + /> + + + + Netmask + setFormData({ ...formData, netmask: text })} + placeholder="255.255.255.0" + keyboardType="numeric" + /> + + + + Broadcast Address + setFormData({ ...formData, broadcast: text })} + placeholder="192.168.1.255" + keyboardType="numeric" + /> + + + + Port + setFormData({ ...formData, port: text })} + placeholder="9" + keyboardType="numeric" + /> + + + + Add Device + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + padding: 20, + }, + header: { + marginBottom: 20, + }, + headerText: { + fontSize: 24, + fontWeight: 'bold', + color: '#333', + marginBottom: 5, + }, + headerSubtext: { + fontSize: 14, + color: '#666', + }, + scanButton: { + backgroundColor: '#007AFF', + borderRadius: 12, + padding: 16, + alignItems: 'center', + marginBottom: 15, + }, + scanButtonDisabled: { + backgroundColor: '#ccc', + }, + scanButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: 'bold', + }, + infoText: { + fontSize: 12, + color: '#999', + textAlign: 'center', + marginBottom: 20, + }, + resultsContainer: { + flex: 1, + }, + resultsHeader: { + fontSize: 18, + fontWeight: 'bold', + color: '#333', + marginBottom: 15, + }, + list: { + gap: 10, + }, + deviceCard: { + backgroundColor: 'rgba(255, 255, 255, 0.8)', + borderRadius: 16, + padding: 15, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 3, + }, + deviceInfo: { + flex: 1, + }, + deviceName: { + fontSize: 16, + fontWeight: 'bold', + color: '#333', + marginBottom: 4, + }, + deviceDetail: { + fontSize: 14, + color: '#666', + marginBottom: 2, + }, + vendorText: { + fontSize: 12, + color: '#999', + fontStyle: 'italic', + }, + addButton: { + width: 44, + height: 44, + backgroundColor: 'rgba(76, 175, 80, 0.9)', + borderRadius: 22, + justifyContent: 'center', + alignItems: 'center', + }, + addButtonText: { + color: '#fff', + fontSize: 24, + fontWeight: 'bold', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + emptyText: { + fontSize: 16, + color: '#999', + textAlign: 'center', + }, + modalBlur: { + flex: 1, + justifyContent: 'flex-end', + }, + modalContent: { + backgroundColor: 'rgba(255, 255, 255, 0.95)', + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + maxHeight: '90%', + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 20, + borderBottomWidth: 1, + borderBottomColor: 'rgba(0, 0, 0, 0.1)', + }, + modalTitle: { + fontSize: 20, + fontWeight: 'bold', + color: '#333', + }, + closeButton: { + fontSize: 24, + color: '#999', + padding: 8, + }, + modalBody: { + padding: 20, + }, + formGroup: { + marginBottom: 16, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#333', + marginBottom: 8, + }, + input: { + backgroundColor: 'rgba(255, 255, 255, 0.8)', + borderWidth: 1, + borderColor: 'rgba(0, 0, 0, 0.1)', + borderRadius: 12, + padding: 14, + fontSize: 16, + }, + hint: { + fontSize: 12, + color: '#666', + marginTop: 4, + }, + saveButton: { + backgroundColor: '#4CAF50', + borderRadius: 12, + padding: 16, + alignItems: 'center', + marginTop: 10, + }, + saveButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: 'bold', + }, +}); diff --git a/components/external-link.tsx b/components/external-link.tsx deleted file mode 100644 index 883e515..0000000 --- a/components/external-link.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Href, Link } from 'expo-router'; -import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser'; -import { type ComponentProps } from 'react'; - -type Props = Omit, 'href'> & { href: Href & string }; - -export function ExternalLink({ href, ...rest }: Props) { - return ( - { - if (process.env.EXPO_OS !== 'web') { - // Prevent the default behavior of linking to the default browser on native. - event.preventDefault(); - // Open the link in an in-app browser. - await openBrowserAsync(href, { - presentationStyle: WebBrowserPresentationStyle.AUTOMATIC, - }); - } - }} - /> - ); -} diff --git a/components/haptic-tab.tsx b/components/haptic-tab.tsx deleted file mode 100644 index 7f3981c..0000000 --- a/components/haptic-tab.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs'; -import { PlatformPressable } from '@react-navigation/elements'; -import * as Haptics from 'expo-haptics'; - -export function HapticTab(props: BottomTabBarButtonProps) { - return ( - { - if (process.env.EXPO_OS === 'ios') { - // Add a soft haptic feedback when pressing down on the tabs. - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - props.onPressIn?.(ev); - }} - /> - ); -} diff --git a/components/hello-wave.tsx b/components/hello-wave.tsx deleted file mode 100644 index 5def547..0000000 --- a/components/hello-wave.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Animated from 'react-native-reanimated'; - -export function HelloWave() { - return ( - - 👋 - - ); -} diff --git a/components/parallax-scroll-view.tsx b/components/parallax-scroll-view.tsx deleted file mode 100644 index 6f674a7..0000000 --- a/components/parallax-scroll-view.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { PropsWithChildren, ReactElement } from 'react'; -import { StyleSheet } from 'react-native'; -import Animated, { - interpolate, - useAnimatedRef, - useAnimatedStyle, - useScrollOffset, -} from 'react-native-reanimated'; - -import { ThemedView } from '@/components/themed-view'; -import { useColorScheme } from '@/hooks/use-color-scheme'; -import { useThemeColor } from '@/hooks/use-theme-color'; - -const HEADER_HEIGHT = 250; - -type Props = PropsWithChildren<{ - headerImage: ReactElement; - headerBackgroundColor: { dark: string; light: string }; -}>; - -export default function ParallaxScrollView({ - children, - headerImage, - headerBackgroundColor, -}: Props) { - const backgroundColor = useThemeColor({}, 'background'); - const colorScheme = useColorScheme() ?? 'light'; - const scrollRef = useAnimatedRef(); - const scrollOffset = useScrollOffset(scrollRef); - const headerAnimatedStyle = useAnimatedStyle(() => { - return { - transform: [ - { - translateY: interpolate( - scrollOffset.value, - [-HEADER_HEIGHT, 0, HEADER_HEIGHT], - [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] - ), - }, - { - scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]), - }, - ], - }; - }); - - return ( - - - {headerImage} - - {children} - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - height: HEADER_HEIGHT, - overflow: 'hidden', - }, - content: { - flex: 1, - padding: 32, - gap: 16, - overflow: 'hidden', - }, -}); diff --git a/components/themed-text.tsx b/components/themed-text.tsx deleted file mode 100644 index d79d0a1..0000000 --- a/components/themed-text.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { StyleSheet, Text, type TextProps } from 'react-native'; - -import { useThemeColor } from '@/hooks/use-theme-color'; - -export type ThemedTextProps = TextProps & { - lightColor?: string; - darkColor?: string; - type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; -}; - -export function ThemedText({ - style, - lightColor, - darkColor, - type = 'default', - ...rest -}: ThemedTextProps) { - const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); - - return ( - - ); -} - -const styles = StyleSheet.create({ - default: { - fontSize: 16, - lineHeight: 24, - }, - defaultSemiBold: { - fontSize: 16, - lineHeight: 24, - fontWeight: '600', - }, - title: { - fontSize: 32, - fontWeight: 'bold', - lineHeight: 32, - }, - subtitle: { - fontSize: 20, - fontWeight: 'bold', - }, - link: { - lineHeight: 30, - fontSize: 16, - color: '#0a7ea4', - }, -}); diff --git a/components/themed-view.tsx b/components/themed-view.tsx deleted file mode 100644 index 6f181d8..0000000 --- a/components/themed-view.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { View, type ViewProps } from 'react-native'; - -import { useThemeColor } from '@/hooks/use-theme-color'; - -export type ThemedViewProps = ViewProps & { - lightColor?: string; - darkColor?: string; -}; - -export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { - const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); - - return ; -} diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx deleted file mode 100644 index 6345fde..0000000 --- a/components/ui/collapsible.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { PropsWithChildren, useState } from 'react'; -import { StyleSheet, TouchableOpacity } from 'react-native'; - -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; - -export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { - const [isOpen, setIsOpen] = useState(false); - const theme = useColorScheme() ?? 'light'; - - return ( - - setIsOpen((value) => !value)} - activeOpacity={0.8}> - - - {title} - - {isOpen && {children}} - - ); -} - -const styles = StyleSheet.create({ - heading: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - content: { - marginTop: 6, - marginLeft: 24, - }, -}); diff --git a/components/ui/icon-symbol.ios.tsx b/components/ui/icon-symbol.ios.tsx deleted file mode 100644 index 9177f4d..0000000 --- a/components/ui/icon-symbol.ios.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols'; -import { StyleProp, ViewStyle } from 'react-native'; - -export function IconSymbol({ - name, - size = 24, - color, - style, - weight = 'regular', -}: { - name: SymbolViewProps['name']; - size?: number; - color: string; - style?: StyleProp; - weight?: SymbolWeight; -}) { - return ( - - ); -} diff --git a/components/ui/icon-symbol.tsx b/components/ui/icon-symbol.tsx deleted file mode 100644 index b7ece6b..0000000 --- a/components/ui/icon-symbol.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// Fallback for using MaterialIcons on Android and web. - -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import { SymbolWeight, SymbolViewProps } from 'expo-symbols'; -import { ComponentProps } from 'react'; -import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native'; - -type IconMapping = Record['name']>; -type IconSymbolName = keyof typeof MAPPING; - -/** - * Add your SF Symbols to Material Icons mappings here. - * - see Material Icons in the [Icons Directory](https://icons.expo.fyi). - * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app. - */ -const MAPPING = { - 'house.fill': 'home', - 'paperplane.fill': 'send', - 'chevron.left.forwardslash.chevron.right': 'code', - 'chevron.right': 'chevron-right', -} as IconMapping; - -/** - * An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web. - * This ensures a consistent look across platforms, and optimal resource usage. - * Icon `name`s are based on SF Symbols and require manual mapping to Material Icons. - */ -export function IconSymbol({ - name, - size = 24, - color, - style, -}: { - name: IconSymbolName; - size?: number; - color: string | OpaqueColorValue; - style?: StyleProp; - weight?: SymbolWeight; -}) { - return ; -} diff --git a/constants/theme.ts b/constants/theme.ts deleted file mode 100644 index f06facd..0000000 --- a/constants/theme.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Below are the colors that are used in the app. The colors are defined in the light and dark mode. - * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. - */ - -import { Platform } from 'react-native'; - -const tintColorLight = '#0a7ea4'; -const tintColorDark = '#fff'; - -export const Colors = { - light: { - text: '#11181C', - background: '#fff', - tint: tintColorLight, - icon: '#687076', - tabIconDefault: '#687076', - tabIconSelected: tintColorLight, - }, - dark: { - text: '#ECEDEE', - background: '#151718', - tint: tintColorDark, - icon: '#9BA1A6', - tabIconDefault: '#9BA1A6', - tabIconSelected: tintColorDark, - }, -}; - -export const Fonts = Platform.select({ - ios: { - /** iOS `UIFontDescriptorSystemDesignDefault` */ - sans: 'system-ui', - /** iOS `UIFontDescriptorSystemDesignSerif` */ - serif: 'ui-serif', - /** iOS `UIFontDescriptorSystemDesignRounded` */ - rounded: 'ui-rounded', - /** iOS `UIFontDescriptorSystemDesignMonospaced` */ - mono: 'ui-monospace', - }, - default: { - sans: 'normal', - serif: 'serif', - rounded: 'normal', - mono: 'monospace', - }, - web: { - sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", - serif: "Georgia, 'Times New Roman', serif", - rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif", - mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", - }, -}); diff --git a/package-lock.json b/package-lock.json index 8a45b51..3b1db2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "remote-wol", "version": "1.0.0", "dependencies": { + "@expo/ui": "~0.2.0-beta.9", "@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", @@ -2181,6 +2183,20 @@ "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", "license": "MIT" }, + "node_modules/@expo/ui": { + "version": "0.2.0-beta.9", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-0.2.0-beta.9.tgz", + "integrity": "sha512-RaBcp0cMe5GykQogJwRZGy4o4JHDLtrr+HaurDPhwPKqVATsV0rR11ysmFe4QX8XWLP/L3od7NOkXUi5ailvaw==", + "license": "MIT", + "dependencies": { + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/@expo/vector-icons": { "version": "15.0.3", "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.0.3.tgz", @@ -2788,6 +2804,18 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -7820,6 +7848,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -8797,6 +8834,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/package.json b/package.json index 3cd1a86..cda4961 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,15 @@ "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "lint": "expo lint" }, "dependencies": { + "@expo/ui": "~0.2.0-beta.9", "@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", @@ -31,17 +33,17 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", - "react-native-worklets": "0.5.1", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", - "react-native-web": "~0.21.0" + "react-native-web": "~0.21.0", + "react-native-worklets": "0.5.1" }, "devDependencies": { "@types/react": "~19.1.0", - "typescript": "~5.9.2", "eslint": "^9.25.0", - "eslint-config-expo": "~10.0.0" + "eslint-config-expo": "~10.0.0", + "typescript": "~5.9.2" }, "private": true } diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..1f008d5 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,92 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import api from '../services/api'; +import { AuthResponse, User } from '../types'; + +interface AuthContextType { + user: User | null; + token: string | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (identity: string, password: string, isSuperuser?: boolean) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + loadAuth(); + }, []); + + const loadAuth = async () => { + try { + const storedToken = await AsyncStorage.getItem('auth_token'); + const storedUser = await AsyncStorage.getItem('auth_user'); + + if (storedToken && storedUser) { + setToken(storedToken); + api.setToken(storedToken); + setUser(JSON.parse(storedUser)); + } + } catch (error) { + console.error('Failed to load auth', error); + } finally { + setIsLoading(false); + } + }; + + const login = async (identity: string, password: string, isSuperuser = false) => { + try { + const response: AuthResponse = await api.authenticate(identity, password, isSuperuser); + + await AsyncStorage.setItem('auth_token', response.token); + await AsyncStorage.setItem('auth_user', JSON.stringify(response.record)); + + setToken(response.token); + setUser(response.record); + } catch (error) { + throw error; + } + }; + + const logout = async () => { + try { + await AsyncStorage.removeItem('auth_token'); + await AsyncStorage.removeItem('auth_user'); + + api.clearToken(); + setToken(null); + setUser(null); + } catch (error) { + console.error('Failed to logout', error); + } + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..dcd6776 --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,245 @@ +import { Device, AuthResponse, NetworkScanResult } from '../types'; + +const API_BASE_URL = 'https://wol.f6knight.duckdns.org/api'; + +class UpSnapAPI { + private token: string | null = null; + + setToken(token: string) { + this.token = token; + } + + getToken(): string | null { + return this.token; + } + + clearToken() { + this.token = null; + } + + private getHeaders(): HeadersInit { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + }; + + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + + return headers; + } + + async authenticate(identity: string, password: string, isSuperuser = false): Promise { + const endpoint = isSuperuser + ? `${API_BASE_URL}/collections/_superusers/auth-with-password` + : `${API_BASE_URL}/collections/users/auth-with-password`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ identity, password }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Authentication failed'); + } + + const data: AuthResponse = await response.json(); + this.token = data.token; + return data; + } + + async getDevices(page = 1, perPage = 30): Promise { + const response = await fetch( + `${API_BASE_URL}/collections/devices/records?page=${page}&perPage=${perPage}`, + { + headers: this.getHeaders(), + } + ); + + if (!response.ok) { + throw new Error('Failed to fetch devices'); + } + + const data = await response.json(); + return data.items; + } + + async getDevice(id: string): Promise { + const response = await fetch( + `${API_BASE_URL}/collections/devices/records/${id}`, + { + headers: this.getHeaders(), + } + ); + + if (!response.ok) { + throw new Error('Failed to fetch device'); + } + + return response.json(); + } + + async createDevice(device: Partial): Promise { + const response = await fetch( + `${API_BASE_URL}/collections/devices/records`, + { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(device), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to create device'); + } + + return response.json(); + } + + async updateDevice(id: string, device: Partial): Promise { + const response = await fetch( + `${API_BASE_URL}/collections/devices/records/${id}`, + { + method: 'PATCH', + headers: this.getHeaders(), + body: JSON.stringify(device), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to update device'); + } + + return response.json(); + } + + async deleteDevice(id: string): Promise { + const response = await fetch( + `${API_BASE_URL}/collections/devices/records/${id}`, + { + method: 'DELETE', + headers: this.getHeaders(), + } + ); + + if (!response.ok) { + throw new Error('Failed to delete device'); + } + } + + async wakeDevice(id: string): Promise { + const response = await fetch( + `${API_BASE_URL}/upsnap/wake/${id}`, + { + headers: this.getHeaders(), + } + ); + + if (!response.ok) { + throw new Error('Failed to wake device'); + } + } + + async wakeGroup(id: string): Promise { + const response = await fetch( + `${API_BASE_URL}/upsnap/wakegroup/${id}`, + { + headers: this.getHeaders(), + } + ); + + if (!response.ok) { + throw new Error('Failed to wake group'); + } + } + + async sleepDevice(id: string): Promise { + const response = await fetch( + `${API_BASE_URL}/upsnap/sleep/${id}`, + { + headers: this.getHeaders(), + } + ); + + if (!response.ok) { + throw new Error('Failed to sleep device'); + } + } + + async rebootDevice(id: string): Promise { + const response = await fetch( + `${API_BASE_URL}/upsnap/reboot/${id}`, + { + headers: this.getHeaders(), + } + ); + + if (!response.ok) { + throw new Error('Failed to reboot device'); + } + } + + async shutdownDevice(id: string): Promise { + const response = await fetch( + `${API_BASE_URL}/upsnap/shutdown/${id}`, + { + headers: this.getHeaders(), + } + ); + + if (!response.ok) { + throw new Error('Failed to shutdown device'); + } + } + + async scanNetwork(): Promise { + const response = await fetch( + `${API_BASE_URL}/upsnap/scan`, + { + headers: this.getHeaders(), + } + ); + + if (!response.ok) { + throw new Error('Failed to scan network'); + } + + const data = await response.json(); + console.log('Raw scan data:', data); + + if (data.devices && Array.isArray(data.devices)) { + return data.devices.map((item: any) => ({ + name: item.name || item.hostname || 'Unknown', + ip: item.ip || item.ip_address || '', + mac: item.mac || item.mac_address || '', + mac_vendor: item.mac_vendor || 'Unknown', + })); + } + + if (Array.isArray(data)) { + return data.map((item: any) => ({ + name: item.name || item.hostname || 'Unknown', + ip: item.ip || item.ip_address || '', + mac: item.mac || item.mac_address || '', + mac_vendor: item.mac_vendor || 'Unknown', + })); + } + + if (data.items && Array.isArray(data.items)) { + return data.items.map((item: any) => ({ + name: item.name || item.hostname || 'Unknown', + ip: item.ip || item.ip_address || '', + mac: item.mac || item.mac_address || '', + mac_vendor: item.mac_vendor || 'Unknown', + })); + } + + return []; + } +} + +export default new UpSnapAPI(); diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..9877c34 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,50 @@ +export interface Device { + id: string; + collectionId: string; + collectionName: string; + name: string; + mac: string; + ip: string; + netmask: string; + broadcast: string; + secureOnPassword: string; + port: number; + groups: string[]; + status: string; + created: string; + updated: string; +} + +export interface AuthResponse { + token: string; + record: User; +} + +export interface User { + id: string; + collectionId: string; + collectionName: string; + username: string; + verified: boolean; + emailVisibility: boolean; + email: string; + created: string; + updated: string; + name: string; + avatar: number; +} + +export interface DeviceGroup { + id: string; + name: string; +} + +export interface NetworkScanResult { + name?: string; + hostname?: string; + ip?: string; + ip_address?: string; + mac?: string; + mac_address?: string; + mac_vendor?: string; +}