feat: complete widget support
10
app.json
@@ -5,14 +5,19 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "remotewol",
|
"scheme": "remotewol-upsnap",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"icon": "./assets/remotewol-ios.icon",
|
"icon": "./assets/remotewol-ios.icon",
|
||||||
"bundleIdentifier": "com.abunchofknowitalls.remotewol-upsnap",
|
"bundleIdentifier": "com.abunchofknowitalls.remotewol-upsnap",
|
||||||
"appleTeamId": "8S7C654DQ4"
|
"appleTeamId": "8S7C654DQ4",
|
||||||
|
"entitlements": {
|
||||||
|
"com.apple.security.application-groups": [
|
||||||
|
"group.abunchofknowitalls.remotewol-upsnap"
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
@@ -38,6 +43,7 @@
|
|||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff",
|
"backgroundColor": "#ffffff",
|
||||||
"dark": {
|
"dark": {
|
||||||
|
"image": "./assets/images/splash-icon-dark.png",
|
||||||
"backgroundColor": "#000000"
|
"backgroundColor": "#000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ContextMenu, Host, Button as SwiftUIButton } from '@expo/ui/swift-ui';
|
import { ContextMenu, Host, Button as SwiftUIButton } from '@expo/ui/swift-ui';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import * as Burnt from 'burnt';
|
||||||
import { SymbolView } from 'expo-symbols';
|
import { SymbolView } from 'expo-symbols';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -15,8 +16,8 @@ import {
|
|||||||
} 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 { Device } from '../../src/types';
|
import { Device } from '../../src/types';
|
||||||
import * as Burnt from 'burnt';
|
|
||||||
|
|
||||||
export default function DeviceListScreen() {
|
export default function DeviceListScreen() {
|
||||||
const colorScheme = useColorScheme() ?? 'light';
|
const colorScheme = useColorScheme() ?? 'light';
|
||||||
@@ -36,6 +37,8 @@ export default function DeviceListScreen() {
|
|||||||
if (showLoading) setIsLoading(true);
|
if (showLoading) setIsLoading(true);
|
||||||
const data = await api.getDevices();
|
const data = await api.getDevices();
|
||||||
setDevices(data);
|
setDevices(data);
|
||||||
|
// Sync devices to iOS widget
|
||||||
|
syncDevicesToWidget(data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// For background/periodic refreshes, avoid interruptive alerts
|
// For background/periodic refreshes, avoid interruptive alerts
|
||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
@@ -272,29 +275,33 @@ export default function DeviceListScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderDevice = ({ item }: { item: Device }) => (
|
const renderDevice = ({ item }: { item: Device }) => {
|
||||||
<Host>
|
const isOnline = item.status?.toLowerCase() === 'online';
|
||||||
<ContextMenu activationMethod="longPress">
|
const isOffline = item.status?.toLowerCase() === 'offline';
|
||||||
<ContextMenu.Items>
|
const hasActions = isOnline || isOffline;
|
||||||
<SwiftUIButton
|
|
||||||
systemImage="trash"
|
return (
|
||||||
role="destructive"
|
<View
|
||||||
onPress={() => handleDelete(item)}
|
style={[
|
||||||
>
|
styles.deviceCard,
|
||||||
Delete Device
|
{
|
||||||
</SwiftUIButton>
|
backgroundColor: cardBg,
|
||||||
</ContextMenu.Items>
|
shadowColor: isDark ? 'rgba(0,0,0,0.6)' : '#000',
|
||||||
<ContextMenu.Trigger>
|
},
|
||||||
<View
|
]}
|
||||||
style={[
|
>
|
||||||
styles.deviceCard,
|
<Host>
|
||||||
{
|
<ContextMenu activationMethod="longPress">
|
||||||
backgroundColor: cardBg,
|
<ContextMenu.Items>
|
||||||
shadowColor: isDark ? 'rgba(0,0,0,0.6)' : '#000',
|
<SwiftUIButton
|
||||||
},
|
systemImage="trash"
|
||||||
]}
|
role="destructive"
|
||||||
>
|
onPress={() => handleDelete(item)}
|
||||||
<View>
|
>
|
||||||
|
Delete Device
|
||||||
|
</SwiftUIButton>
|
||||||
|
</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 }]}>
|
||||||
@@ -330,49 +337,51 @@ export default function DeviceListScreen() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
</ContextMenu>
|
||||||
|
</Host>
|
||||||
|
|
||||||
<View style={styles.deviceActions}>
|
{hasActions && (
|
||||||
{item.status?.toLowerCase() === 'offline' && (
|
<View style={styles.deviceActions}>
|
||||||
<ActionIcon
|
{isOffline && (
|
||||||
name="Wake"
|
<ActionIcon
|
||||||
symbolName="bolt.circle.fill"
|
name="Wake"
|
||||||
fallbackName="flash"
|
symbolName="bolt.circle.fill"
|
||||||
color="#4CAF50"
|
fallbackName="flash"
|
||||||
onPress={() => handleWake(item)}
|
color="#4CAF50"
|
||||||
/>
|
onPress={() => handleWake(item)}
|
||||||
)}
|
/>
|
||||||
{item.status?.toLowerCase() === 'online' && (
|
)}
|
||||||
<>
|
{isOnline && (
|
||||||
<ActionIcon
|
<>
|
||||||
name="Sleep"
|
<ActionIcon
|
||||||
symbolName="moon.circle.fill"
|
name="Sleep"
|
||||||
fallbackName="moon"
|
symbolName="moon.circle.fill"
|
||||||
color="#FF9800"
|
fallbackName="moon"
|
||||||
onPress={() => handleSleep(item)}
|
color="#FF9800"
|
||||||
/>
|
onPress={() => handleSleep(item)}
|
||||||
<ActionIcon
|
/>
|
||||||
name="Reboot"
|
<ActionIcon
|
||||||
symbolName="arrow.clockwise.circle.fill"
|
name="Reboot"
|
||||||
fallbackName="refresh"
|
symbolName="arrow.clockwise.circle.fill"
|
||||||
color="#2196F3"
|
fallbackName="refresh"
|
||||||
onPress={() => handleReboot(item)}
|
color="#2196F3"
|
||||||
/>
|
onPress={() => handleReboot(item)}
|
||||||
<ActionIcon
|
/>
|
||||||
name="Shutdown"
|
<ActionIcon
|
||||||
symbolName="power.circle.fill"
|
name="Shutdown"
|
||||||
fallbackName="power"
|
symbolName="power.circle.fill"
|
||||||
color="#f44336"
|
fallbackName="power"
|
||||||
onPress={() => handleShutdown(item)}
|
color="#f44336"
|
||||||
/>
|
onPress={() => handleShutdown(item)}
|
||||||
</>
|
/>
|
||||||
)}
|
</>
|
||||||
</View>
|
)}
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</ContextMenu.Trigger>
|
)}
|
||||||
</ContextMenu>
|
</View>
|
||||||
</Host>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -389,9 +398,11 @@ export default function DeviceListScreen() {
|
|||||||
renderItem={renderDevice}
|
renderItem={renderDevice}
|
||||||
keyExtractor={item => item.id}
|
keyExtractor={item => item.id}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
}
|
}
|
||||||
|
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 }]}>
|
||||||
|
|||||||
135
app/_layout.tsx
@@ -1,77 +1,82 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { Stack, useRouter, useSegments } from "expo-router";
|
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||||
import { TouchableOpacity, Text } from "react-native";
|
import { Text, TouchableOpacity } from 'react-native';
|
||||||
import { useColorScheme } from "../hooks/use-color-scheme";
|
import { useColorScheme } from '../hooks/use-color-scheme';
|
||||||
import { AuthProvider } from "../src/context/AuthContext";
|
import { AuthProvider } from '../src/context/AuthContext';
|
||||||
|
|
||||||
function DevicesHeader() {
|
function DevicesHeader() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isDark = useColorScheme() === "dark";
|
const isDark = useColorScheme() === 'dark';
|
||||||
const activityColor = isDark ? "#0A84FF" : "#007AFF";
|
const activityColor = isDark ? '#0A84FF' : '#007AFF';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.push("/scan-devices")}
|
onPress={() => router.push('/scan-devices')}
|
||||||
style={{ paddingHorizontal: 8 }}
|
style={{ paddingHorizontal: 8 }}
|
||||||
>
|
>
|
||||||
<Ionicons name="add-circle-outline" size={24} color={activityColor} />
|
<Ionicons name="add-circle-outline" size={24} color={activityColor} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsTitle(props: { title: string }) {
|
function TabsTitle(props: { title: string }) {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const last = segments[segments.length - 1];
|
const last = segments[segments.length - 1];
|
||||||
const isDark = useColorScheme() === "dark";
|
const isDark = useColorScheme() === 'dark';
|
||||||
const titleColor = isDark ? "#fff" : "#000";
|
const titleColor = isDark ? '#fff' : '#000';
|
||||||
const title = last === "settings" ? "Settings" : props.title;
|
const title = last === 'settings' ? 'Settings' : props.title;
|
||||||
return (
|
return (
|
||||||
<Text style={{ color: titleColor, fontSize: 17, fontWeight: "600" }}>
|
<Text style={{ color: titleColor, fontSize: 17, fontWeight: '600' }}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</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 segments = useSegments();
|
||||||
const last = segments[segments.length - 1];
|
const last = segments[segments.length - 1];
|
||||||
const isIndex = last === "(tabs)" || last === undefined;
|
const isIndex = last === '(tabs)' || last === undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Stack>
|
<Stack>
|
||||||
{/* Root index that performs auth redirect (app/index.tsx) */}
|
{/* Root index that performs auth redirect (app/index.tsx) */}
|
||||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||||
{/* Tabs parent - render tabs with dynamic header */}
|
{/* Tabs parent - render tabs with dynamic header */}
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(tabs)"
|
name="(tabs)"
|
||||||
options={
|
options={
|
||||||
{
|
{
|
||||||
headerTitle: () => <TabsTitle title="Devices" />,
|
headerTitle: () => <TabsTitle title="Devices" />,
|
||||||
headerRight: isIndex ? () => <DevicesHeader /> : undefined,
|
headerRight: isIndex ? () => <DevicesHeader /> : undefined,
|
||||||
headerRightContainerStyle: isIndex
|
headerRightContainerStyle: isIndex
|
||||||
? undefined
|
? undefined
|
||||||
: { width: 0, paddingRight: 0 },
|
: { width: 0, paddingRight: 0 },
|
||||||
headerStyle: { backgroundColor: bgColor },
|
headerStyle: { backgroundColor: bgColor },
|
||||||
headerTintColor: titleColor,
|
headerTintColor: titleColor,
|
||||||
} as any
|
} as any
|
||||||
}
|
}
|
||||||
/>{" "}
|
/>{' '}
|
||||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
<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" />,
|
headerTitle: () => <TabsTitle title="Scan Devices" />,
|
||||||
headerStyle: { backgroundColor: bgColor },
|
headerStyle: { backgroundColor: bgColor },
|
||||||
headerBackButtonDisplayMode: "minimal",
|
headerBackButtonDisplayMode: 'minimal',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
{/* Deep link action handler - no header, immediately redirects */}
|
||||||
</AuthProvider>
|
<Stack.Screen
|
||||||
);
|
name="action/[action]/[deviceId]"
|
||||||
|
options={{ headerShown: false, animation: 'none' }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
108
app/action/[action]/[deviceId].tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { CommonActions, useNavigation } from '@react-navigation/native';
|
||||||
|
import * as Burnt from 'burnt';
|
||||||
|
import { Redirect, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import api from '../../../src/services/api';
|
||||||
|
|
||||||
|
type DeviceAction = 'wake' | 'sleep' | 'restart' | 'shutdown';
|
||||||
|
|
||||||
|
export default function ActionHandler() {
|
||||||
|
const params = useLocalSearchParams<{ action: string; deviceId: string }>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const hasExecuted = useRef(false);
|
||||||
|
|
||||||
|
const { action, deviceId } = params;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fire action once
|
||||||
|
if (!hasExecuted.current && action && deviceId) {
|
||||||
|
hasExecuted.current = true;
|
||||||
|
executeAction(action as DeviceAction, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset navigation to tabs - clears entire stack so no back button
|
||||||
|
navigation.dispatch(
|
||||||
|
CommonActions.reset({
|
||||||
|
index: 0,
|
||||||
|
routes: [{ name: '(tabs)' }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [action, deviceId, navigation]);
|
||||||
|
|
||||||
|
// Show nothing while redirecting
|
||||||
|
return <Redirect href={"/"} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeAction(
|
||||||
|
action: DeviceAction,
|
||||||
|
deviceId: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Get device name for toast
|
||||||
|
let deviceName = 'device';
|
||||||
|
try {
|
||||||
|
const device = await api.getDevice(deviceId);
|
||||||
|
deviceName = device.name;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not fetch device name:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show sending toast
|
||||||
|
const sendingMessages: Record<DeviceAction, string> = {
|
||||||
|
wake: `Sending wake command to ${deviceName}...`,
|
||||||
|
sleep: `Sending sleep command to ${deviceName}...`,
|
||||||
|
restart: `Sending restart command to ${deviceName}...`,
|
||||||
|
shutdown: `Sending shutdown command to ${deviceName}...`,
|
||||||
|
};
|
||||||
|
|
||||||
|
Burnt.toast({
|
||||||
|
title: sendingMessages[action],
|
||||||
|
preset: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute the action
|
||||||
|
try {
|
||||||
|
switch (action) {
|
||||||
|
case 'wake':
|
||||||
|
await api.wakeDevice(deviceId);
|
||||||
|
Burnt.toast({
|
||||||
|
title: 'Success',
|
||||||
|
preset: 'done',
|
||||||
|
message: `Waking ${deviceName} up.`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'sleep':
|
||||||
|
await api.sleepDevice(deviceId);
|
||||||
|
Burnt.toast({
|
||||||
|
title: 'Success',
|
||||||
|
preset: 'done',
|
||||||
|
message: `Sending ${deviceName} to sleep.`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'restart':
|
||||||
|
await api.rebootDevice(deviceId);
|
||||||
|
Burnt.toast({
|
||||||
|
title: 'Success',
|
||||||
|
preset: 'done',
|
||||||
|
message: `Rebooting ${deviceName}.`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'shutdown':
|
||||||
|
await api.shutdownDevice(deviceId);
|
||||||
|
Burnt.toast({
|
||||||
|
title: 'Success',
|
||||||
|
preset: 'done',
|
||||||
|
message: `Shutting down ${deviceName}.`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
Burnt.toast({
|
||||||
|
title: 'Error',
|
||||||
|
preset: 'error',
|
||||||
|
message: error.message || `Failed to ${action} ${deviceName}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
assets/images/splash-icon-dark.png
Normal file
|
After Width: | Height: | Size: 734 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 780 KiB |
61
src/services/widgetSync.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ExtensionStorage } from '@bacons/apple-targets';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { Device } from '../types';
|
||||||
|
|
||||||
|
// Create a storage object with the App Group
|
||||||
|
const storage =
|
||||||
|
Platform.OS === 'ios'
|
||||||
|
? new ExtensionStorage('group.abunchofknowitalls.remotewol-upsnap')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
interface WidgetDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mac: string;
|
||||||
|
ip: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs device data to the iOS widget via App Group shared storage.
|
||||||
|
* This allows the widget to display device information and create action deep links.
|
||||||
|
*/
|
||||||
|
export function syncDevicesToWidget(devices: Device[]): void {
|
||||||
|
if (!storage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Transform devices to the format expected by the widget
|
||||||
|
const widgetDevices: WidgetDevice[] = devices.map(device => ({
|
||||||
|
id: device.id,
|
||||||
|
name: device.name,
|
||||||
|
mac: device.mac,
|
||||||
|
ip: device.ip,
|
||||||
|
status: device.status || 'unknown',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const devicesJson = JSON.stringify(widgetDevices);
|
||||||
|
storage.set('devices', devicesJson);
|
||||||
|
|
||||||
|
// Refresh widgets to pick up the new data
|
||||||
|
ExtensionStorage.reloadWidget();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync devices to widget:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes all widget timelines to pick up new data.
|
||||||
|
*/
|
||||||
|
export function refreshWidgets(): void {
|
||||||
|
if (!storage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ExtensionStorage.reloadWidget();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh widgets:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,80 @@
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
import AppIntents
|
import AppIntents
|
||||||
|
|
||||||
struct ConfigurationAppIntent: WidgetConfigurationIntent {
|
// MARK: - Shared Data Access
|
||||||
static var title: LocalizedStringResource { "Configuration" }
|
|
||||||
static var description: IntentDescription { "This is an example widget." }
|
|
||||||
|
|
||||||
// An example configurable parameter.
|
struct SharedDeviceData {
|
||||||
@Parameter(title: "Favorite Emoji", default: "😃")
|
static let appGroupIdentifier = "group.abunchofknowitalls.remotewol-upsnap"
|
||||||
var favoriteEmoji: String
|
|
||||||
|
static var sharedDefaults: UserDefaults? {
|
||||||
|
UserDefaults(suiteName: appGroupIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getDevices() -> [DeviceInfo] {
|
||||||
|
guard let defaults = sharedDefaults,
|
||||||
|
let jsonString = defaults.string(forKey: "devices"),
|
||||||
|
let data = jsonString.data(using: .utf8),
|
||||||
|
let devices = try? JSONDecoder().decode([DeviceInfo].self, from: data) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getDevice(id: String) -> DeviceInfo? {
|
||||||
|
return getDevices().first { $0.id == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Device Model
|
||||||
|
|
||||||
|
struct DeviceInfo: Codable, Hashable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let mac: String
|
||||||
|
let ip: String
|
||||||
|
let status: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Device Entity for AppIntents
|
||||||
|
|
||||||
|
struct DeviceEntity: AppEntity {
|
||||||
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Device"
|
||||||
|
static var defaultQuery = DeviceQuery()
|
||||||
|
|
||||||
|
var id: String
|
||||||
|
var name: String
|
||||||
|
var mac: String
|
||||||
|
|
||||||
|
var displayRepresentation: DisplayRepresentation {
|
||||||
|
DisplayRepresentation(title: "\(name)", subtitle: "\(mac)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeviceQuery: EntityQuery {
|
||||||
|
func entities(for identifiers: [String]) async throws -> [DeviceEntity] {
|
||||||
|
let devices = SharedDeviceData.getDevices()
|
||||||
|
return devices
|
||||||
|
.filter { identifiers.contains($0.id) }
|
||||||
|
.map { DeviceEntity(id: $0.id, name: $0.name, mac: $0.mac) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestedEntities() async throws -> [DeviceEntity] {
|
||||||
|
let devices = SharedDeviceData.getDevices()
|
||||||
|
return devices.map { DeviceEntity(id: $0.id, name: $0.name, mac: $0.mac) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultResult() async -> DeviceEntity? {
|
||||||
|
let devices = SharedDeviceData.getDevices()
|
||||||
|
return devices.first.map { DeviceEntity(id: $0.id, name: $0.name, mac: $0.mac) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget Configuration Intent
|
||||||
|
|
||||||
|
struct ConfigurationAppIntent: WidgetConfigurationIntent {
|
||||||
|
static var title: LocalizedStringResource { "Device Control" }
|
||||||
|
static var description: IntentDescription { "Select a device to control from your home screen." }
|
||||||
|
|
||||||
|
@Parameter(title: "Device")
|
||||||
|
var device: DeviceEntity?
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 340 B |
|
After Width: | Height: | Size: 703 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 503 B |
|
After Width: | Height: | Size: 963 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 703 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
122
targets/widget/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"idiom": "iphone",
|
||||||
|
"size": "20x20",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "App-Icon-20x20@2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "iphone",
|
||||||
|
"size": "20x20",
|
||||||
|
"scale": "3x",
|
||||||
|
"filename": "App-Icon-20x20@3x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "iphone",
|
||||||
|
"size": "29x29",
|
||||||
|
"scale": "1x",
|
||||||
|
"filename": "App-Icon-29x29@1x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "iphone",
|
||||||
|
"size": "29x29",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "App-Icon-29x29@2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "iphone",
|
||||||
|
"size": "29x29",
|
||||||
|
"scale": "3x",
|
||||||
|
"filename": "App-Icon-29x29@3x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "iphone",
|
||||||
|
"size": "40x40",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "App-Icon-40x40@2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "iphone",
|
||||||
|
"size": "40x40",
|
||||||
|
"scale": "3x",
|
||||||
|
"filename": "App-Icon-40x40@3x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "iphone",
|
||||||
|
"size": "60x60",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "App-Icon-60x60@2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "iphone",
|
||||||
|
"size": "60x60",
|
||||||
|
"scale": "3x",
|
||||||
|
"filename": "App-Icon-60x60@3x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "ipad",
|
||||||
|
"size": "20x20",
|
||||||
|
"scale": "1x",
|
||||||
|
"filename": "App-Icon-20x20@1x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "ipad",
|
||||||
|
"size": "20x20",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "App-Icon-20x20@2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "ipad",
|
||||||
|
"size": "29x29",
|
||||||
|
"scale": "1x",
|
||||||
|
"filename": "App-Icon-29x29@1x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "ipad",
|
||||||
|
"size": "29x29",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "App-Icon-29x29@2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "ipad",
|
||||||
|
"size": "40x40",
|
||||||
|
"scale": "1x",
|
||||||
|
"filename": "App-Icon-40x40@1x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "ipad",
|
||||||
|
"size": "40x40",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "App-Icon-40x40@2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "ipad",
|
||||||
|
"size": "76x76",
|
||||||
|
"scale": "1x",
|
||||||
|
"filename": "App-Icon-76x76@1x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "ipad",
|
||||||
|
"size": "76x76",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "App-Icon-76x76@2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "ipad",
|
||||||
|
"size": "83.5x83.5",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "App-Icon-83.5x83.5@2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "ios-marketing",
|
||||||
|
"size": "1024x1024",
|
||||||
|
"scale": "1x",
|
||||||
|
"filename": "ItunesArtwork@2x.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"version": 1,
|
||||||
|
"author": "expo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 38 KiB |
6
targets/widget/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,69 +2,92 @@ import AppIntents
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
|
// MARK: - Control Widget for Quick Wake Action
|
||||||
|
|
||||||
struct widgetControl: ControlWidget {
|
struct widgetControl: ControlWidget {
|
||||||
static let kind: String = "com.developer.example.widget"
|
static let kind: String = "wakecontrol"
|
||||||
|
|
||||||
var body: some ControlWidgetConfiguration {
|
var body: some ControlWidgetConfiguration {
|
||||||
AppIntentControlConfiguration(
|
AppIntentControlConfiguration(
|
||||||
kind: Self.kind,
|
kind: Self.kind,
|
||||||
provider: Provider()
|
provider: WakeControlProvider()
|
||||||
) { value in
|
) { value in
|
||||||
ControlWidgetToggle(
|
ControlWidgetButton(action: WakeDeviceIntent(deviceId: value.deviceId, deviceName: value.deviceName)) {
|
||||||
"Start Timer",
|
Label(value.deviceName, systemImage: "bolt.fill")
|
||||||
isOn: value.isRunning,
|
|
||||||
action: StartTimerIntent(value.name)
|
|
||||||
) { isRunning in
|
|
||||||
Label(isRunning ? "On" : "Off", systemImage: "timer")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.displayName("Timer")
|
.displayName("Wake Device")
|
||||||
.description("A an example control that runs a timer.")
|
.description("Quickly wake a device from Control Center.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Control Widget Value
|
||||||
|
|
||||||
extension widgetControl {
|
extension widgetControl {
|
||||||
struct Value {
|
struct Value {
|
||||||
var isRunning: Bool
|
var deviceId: String
|
||||||
var name: String
|
var deviceName: String
|
||||||
}
|
|
||||||
|
|
||||||
struct Provider: AppIntentControlValueProvider {
|
|
||||||
func previewValue(configuration: TimerConfiguration) -> Value {
|
|
||||||
widgetControl.Value(isRunning: false, name: configuration.timerName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentValue(configuration: TimerConfiguration) async throws -> Value {
|
|
||||||
let isRunning = true // Check if the timer is running
|
|
||||||
return widgetControl.Value(isRunning: isRunning, name: configuration.timerName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TimerConfiguration: ControlConfigurationIntent {
|
// MARK: - Control Widget Provider
|
||||||
static let title: LocalizedStringResource = "Timer Name Configuration"
|
|
||||||
|
|
||||||
@Parameter(title: "Timer Name", default: "Timer")
|
struct WakeControlProvider: AppIntentControlValueProvider {
|
||||||
var timerName: String
|
func previewValue(configuration: WakeControlConfiguration) -> widgetControl.Value {
|
||||||
}
|
widgetControl.Value(
|
||||||
|
deviceId: configuration.device?.id ?? "",
|
||||||
struct StartTimerIntent: SetValueIntent {
|
deviceName: configuration.device?.name ?? "Select Device"
|
||||||
static let title: LocalizedStringResource = "Start a timer"
|
)
|
||||||
|
|
||||||
@Parameter(title: "Timer Name")
|
|
||||||
var name: String
|
|
||||||
|
|
||||||
@Parameter(title: "Timer is running")
|
|
||||||
var value: Bool
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
|
|
||||||
init(_ name: String) {
|
|
||||||
self.name = name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func perform() async throws -> some IntentResult {
|
func currentValue(configuration: WakeControlConfiguration) async throws -> widgetControl.Value {
|
||||||
// Start the timer…
|
widgetControl.Value(
|
||||||
return .result()
|
deviceId: configuration.device?.id ?? "",
|
||||||
|
deviceName: configuration.device?.name ?? "Select Device"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Control Widget Configuration Intent
|
||||||
|
|
||||||
|
struct WakeControlConfiguration: ControlConfigurationIntent {
|
||||||
|
static let title: LocalizedStringResource = "Device to Wake"
|
||||||
|
|
||||||
|
@Parameter(title: "Device")
|
||||||
|
var device: DeviceEntity?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wake Device Intent (Opens Deep Link)
|
||||||
|
|
||||||
|
struct WakeDeviceIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Wake Device"
|
||||||
|
static var description = IntentDescription("Wakes the selected device using Wake-on-LAN.")
|
||||||
|
static var openAppWhenRun: Bool = true
|
||||||
|
static var isDiscoverable: Bool = true
|
||||||
|
|
||||||
|
@Parameter(title: "Device ID")
|
||||||
|
var deviceId: String
|
||||||
|
|
||||||
|
@Parameter(title: "Device Name")
|
||||||
|
var deviceName: String
|
||||||
|
|
||||||
|
// static var parameterSummary: some ParameterSummary {
|
||||||
|
// Summary("Wake \(\.$deviceName)")
|
||||||
|
// }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.deviceId = ""
|
||||||
|
self.deviceName = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
init(deviceId: String, deviceName: String) {
|
||||||
|
self.deviceId = deviceId
|
||||||
|
self.deviceName = deviceName
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult & OpensIntent {
|
||||||
|
print("WakeDeviceIntent performed for device: \(deviceName) (id: \(deviceId))")
|
||||||
|
let url = URL(string: "remotewol-upsnap://actions/wake/\(deviceId)")!
|
||||||
|
return .result(opensIntent: OpenURLIntent(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import ActivityKit
|
|
||||||
import WidgetKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct WidgetAttributes: ActivityAttributes {
|
|
||||||
public struct ContentState: Codable, Hashable {
|
|
||||||
// Dynamic stateful properties about your activity go here!
|
|
||||||
var emoji: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fixed non-changing properties about your activity go here!
|
|
||||||
var name: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WidgetLiveActivity: Widget {
|
|
||||||
var body: some WidgetConfiguration {
|
|
||||||
ActivityConfiguration(for: WidgetAttributes.self) { context in
|
|
||||||
// Lock screen/banner UI goes here
|
|
||||||
VStack {
|
|
||||||
Text("Hello \(context.state.emoji)")
|
|
||||||
}
|
|
||||||
.activityBackgroundTint(Color.cyan)
|
|
||||||
.activitySystemActionForegroundColor(Color.black)
|
|
||||||
|
|
||||||
} dynamicIsland: { context in
|
|
||||||
DynamicIsland {
|
|
||||||
// Expanded UI goes here. Compose the expanded UI through
|
|
||||||
// various regions, like leading/trailing/center/bottom
|
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
|
||||||
Text("Leading")
|
|
||||||
}
|
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
|
||||||
Text("Trailing")
|
|
||||||
}
|
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
|
||||||
Text("Bottom \(context.state.emoji)")
|
|
||||||
// more content
|
|
||||||
}
|
|
||||||
} compactLeading: {
|
|
||||||
Text("L")
|
|
||||||
} compactTrailing: {
|
|
||||||
Text("T \(context.state.emoji)")
|
|
||||||
} minimal: {
|
|
||||||
Text(context.state.emoji)
|
|
||||||
}
|
|
||||||
.widgetURL(URL(string: "https://www.expo.dev"))
|
|
||||||
.keylineTint(Color.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension WidgetAttributes {
|
|
||||||
fileprivate static var preview: WidgetAttributes {
|
|
||||||
WidgetAttributes(name: "World")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension WidgetAttributes.ContentState {
|
|
||||||
fileprivate static var smiley: WidgetAttributes.ContentState {
|
|
||||||
WidgetAttributes.ContentState(emoji: "😀")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate static var starEyes: WidgetAttributes.ContentState {
|
|
||||||
WidgetAttributes.ContentState(emoji: "🤩")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Notification", as: .content, using: WidgetAttributes.preview) {
|
|
||||||
WidgetLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
WidgetAttributes.ContentState.smiley
|
|
||||||
WidgetAttributes.ContentState.starEyes
|
|
||||||
}
|
|
||||||
@@ -2,5 +2,9 @@
|
|||||||
module.exports = config => ({
|
module.exports = config => ({
|
||||||
type: "widget",
|
type: "widget",
|
||||||
icon: 'https://github.com/expo.png',
|
icon: 'https://github.com/expo.png',
|
||||||
entitlements: { /* Add entitlements */ },
|
entitlements: {
|
||||||
|
"com.apple.security.application-groups": [
|
||||||
|
"group.abunchofknowitalls.remotewol-upsnap"
|
||||||
|
]
|
||||||
|
},
|
||||||
});
|
});
|
||||||
10
targets/widget/generated.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.abunchofknowitalls.remotewol-upsnap</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -6,7 +6,6 @@ struct exportWidgets: WidgetBundle {
|
|||||||
var body: some Widget {
|
var body: some Widget {
|
||||||
// Export widgets here
|
// Export widgets here
|
||||||
widget()
|
widget()
|
||||||
widgetControl()
|
// widgetControl()
|
||||||
WidgetLiveActivity()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
targets/widget/remotewol-ios.icon/Assets/Image.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
targets/widget/remotewol-ios.icon/Assets/display 2.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
targets/widget/remotewol-ios.icon/Assets/display 3.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
targets/widget/remotewol-ios.icon/Assets/display.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
66
targets/widget/remotewol-ios.icon/icon.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"automatic-gradient" : "extended-gray:1.00000,1.00000"
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"hidden" : false,
|
||||||
|
"image-name" : "Image.png",
|
||||||
|
"name" : "Image",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 0.65,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
-52.942187500000045
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fill-specializations" : [
|
||||||
|
{
|
||||||
|
"appearance" : "dark",
|
||||||
|
"value" : "none"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"image-name-specializations" : [
|
||||||
|
{
|
||||||
|
"value" : "display 2.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "dark",
|
||||||
|
"value" : "display.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "tinted",
|
||||||
|
"value" : "display 3.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name" : "display 3",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 1.25,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
-10.625
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,81 +1,202 @@
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct Provider: AppIntentTimelineProvider {
|
// MARK: - Timeline Entry
|
||||||
func placeholder(in context: Context) -> SimpleEntry {
|
|
||||||
SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
|
struct DeviceEntry: TimelineEntry {
|
||||||
SimpleEntry(date: Date(), configuration: configuration)
|
|
||||||
}
|
|
||||||
|
|
||||||
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
|
|
||||||
var entries: [SimpleEntry] = []
|
|
||||||
|
|
||||||
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
|
|
||||||
let currentDate = Date()
|
|
||||||
for hourOffset in 0 ..< 5 {
|
|
||||||
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
|
|
||||||
let entry = SimpleEntry(date: entryDate, configuration: configuration)
|
|
||||||
entries.append(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Timeline(entries: entries, policy: .atEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// func relevances() async -> WidgetRelevances<ConfigurationAppIntent> {
|
|
||||||
// // Generate a list containing the contexts this widget is relevant in.
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SimpleEntry: TimelineEntry {
|
|
||||||
let date: Date
|
let date: Date
|
||||||
let configuration: ConfigurationAppIntent
|
let configuration: ConfigurationAppIntent
|
||||||
|
let device: DeviceInfo?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct widgetEntryView : View {
|
// MARK: - Timeline Provider
|
||||||
|
|
||||||
|
struct Provider: AppIntentTimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> DeviceEntry {
|
||||||
|
DeviceEntry(
|
||||||
|
date: Date(),
|
||||||
|
configuration: ConfigurationAppIntent(),
|
||||||
|
device: DeviceInfo(id: "placeholder", name: "My Computer", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", status: "unknown")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> DeviceEntry {
|
||||||
|
let device = configuration.device.flatMap { SharedDeviceData.getDevice(id: $0.id) }
|
||||||
|
return DeviceEntry(date: Date(), configuration: configuration, device: device)
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<DeviceEntry> {
|
||||||
|
let device = configuration.device.flatMap { SharedDeviceData.getDevice(id: $0.id) }
|
||||||
|
let entry = DeviceEntry(date: Date(), configuration: configuration, device: device)
|
||||||
|
|
||||||
|
// Widgets don't update frequently enough for status, so we just provide a static entry
|
||||||
|
// Refresh every 30 minutes to pick up device list changes
|
||||||
|
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: Date())!
|
||||||
|
return Timeline(entries: [entry], policy: .after(nextUpdate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Deep Link Helper
|
||||||
|
|
||||||
|
enum DeviceAction: String {
|
||||||
|
case wake
|
||||||
|
case sleep
|
||||||
|
case restart
|
||||||
|
case shutdown
|
||||||
|
|
||||||
|
func url(for deviceId: String) -> URL {
|
||||||
|
URL(string: "remotewol-upsnap://action/\(self.rawValue)/\(deviceId)")!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget View
|
||||||
|
|
||||||
|
struct DeviceWidgetEntryView: View {
|
||||||
var entry: Provider.Entry
|
var entry: Provider.Entry
|
||||||
|
@Environment(\.widgetFamily) var widgetFamily
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
if let device = entry.device {
|
||||||
Text("Time:")
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(entry.date, style: .time)
|
// Device Info Header
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Favorite Emoji:")
|
Text(device.name)
|
||||||
Text(entry.configuration.favoriteEmoji)
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(device.mac)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
|
||||||
|
// Action Buttons
|
||||||
|
if widgetFamily == .systemSmall {
|
||||||
|
// Small widget: 2x2 grid
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ActionButton(action: .wake, deviceId: device.id)
|
||||||
|
ActionButton(action: .sleep, deviceId: device.id)
|
||||||
|
}
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ActionButton(action: .restart, deviceId: device.id)
|
||||||
|
ActionButton(action: .shutdown, deviceId: device.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Medium/Large widget: horizontal layout
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ActionButton(action: .wake, deviceId: device.id)
|
||||||
|
ActionButton(action: .sleep, deviceId: device.id)
|
||||||
|
ActionButton(action: .restart, deviceId: device.id)
|
||||||
|
ActionButton(action: .shutdown, deviceId: device.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} else {
|
||||||
|
// No device selected
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "desktopcomputer")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("Select a Device")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("Long press to configure")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary.opacity(0.7))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct widget: Widget {
|
// MARK: - Action Button
|
||||||
|
|
||||||
|
struct ActionButton: View {
|
||||||
|
let action: DeviceAction
|
||||||
|
let deviceId: String
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch action {
|
||||||
|
case .wake: return "bolt.fill"
|
||||||
|
case .sleep: return "moon.fill"
|
||||||
|
case .restart: return "arrow.clockwise"
|
||||||
|
case .shutdown: return "power"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch action {
|
||||||
|
case .wake: return .green
|
||||||
|
case .sleep: return .orange
|
||||||
|
case .restart: return .blue
|
||||||
|
case .shutdown: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch action {
|
||||||
|
case .wake: return "Wake"
|
||||||
|
case .sleep: return "Sleep"
|
||||||
|
case .restart: return "Restart"
|
||||||
|
case .shutdown: return "Shut Down"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Link(destination: action.url(for: deviceId)) {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(color)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 36)
|
||||||
|
.background(color.opacity(0.15))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget Definition
|
||||||
|
|
||||||
|
struct DeviceControlWidget: Widget {
|
||||||
let kind: String = "widget"
|
let kind: String = "widget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
|
||||||
widgetEntryView(entry: entry)
|
DeviceWidgetEntryView(entry: entry)
|
||||||
.containerBackground(.fill.tertiary, for: .widget)
|
.containerBackground(.fill.tertiary, for: .widget)
|
||||||
}
|
}
|
||||||
|
.configurationDisplayName("Device Control")
|
||||||
|
.description("Control your devices with wake, sleep, restart, and shutdown actions.")
|
||||||
|
.supportedFamilies([.systemSmall, .systemMedium])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConfigurationAppIntent {
|
// Keep legacy name for backwards compatibility with existing widget installations
|
||||||
fileprivate static var smiley: ConfigurationAppIntent {
|
typealias widget = DeviceControlWidget
|
||||||
let intent = ConfigurationAppIntent()
|
|
||||||
intent.favoriteEmoji = "😀"
|
// MARK: - Preview
|
||||||
return intent
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate static var starEyes: ConfigurationAppIntent {
|
|
||||||
let intent = ConfigurationAppIntent()
|
|
||||||
intent.favoriteEmoji = "🤩"
|
|
||||||
return intent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview(as: .systemSmall) {
|
#Preview(as: .systemSmall) {
|
||||||
widget()
|
DeviceControlWidget()
|
||||||
} timeline: {
|
} timeline: {
|
||||||
SimpleEntry(date: .now, configuration: .smiley)
|
DeviceEntry(
|
||||||
SimpleEntry(date: .now, configuration: .starEyes)
|
date: .now,
|
||||||
|
configuration: ConfigurationAppIntent(),
|
||||||
|
device: DeviceInfo(id: "preview", name: "Gaming PC", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", status: "online")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview(as: .systemMedium) {
|
||||||
|
DeviceControlWidget()
|
||||||
|
} timeline: {
|
||||||
|
DeviceEntry(
|
||||||
|
date: .now,
|
||||||
|
configuration: ConfigurationAppIntent(),
|
||||||
|
device: DeviceInfo(id: "preview", name: "Home Server", mac: "11:22:33:44:55:66", ip: "192.168.1.50", status: "offline")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||