feat: complete widget support
@@ -1,11 +1,80 @@
|
||||
import WidgetKit
|
||||
import AppIntents
|
||||
|
||||
struct ConfigurationAppIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource { "Configuration" }
|
||||
static var description: IntentDescription { "This is an example widget." }
|
||||
// MARK: - Shared Data Access
|
||||
|
||||
// An example configurable parameter.
|
||||
@Parameter(title: "Favorite Emoji", default: "😃")
|
||||
var favoriteEmoji: String
|
||||
struct SharedDeviceData {
|
||||
static let appGroupIdentifier = "group.abunchofknowitalls.remotewol-upsnap"
|
||||
|
||||
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 WidgetKit
|
||||
|
||||
// MARK: - Control Widget for Quick Wake Action
|
||||
|
||||
struct widgetControl: ControlWidget {
|
||||
static let kind: String = "com.developer.example.widget"
|
||||
static let kind: String = "wakecontrol"
|
||||
|
||||
var body: some ControlWidgetConfiguration {
|
||||
AppIntentControlConfiguration(
|
||||
kind: Self.kind,
|
||||
provider: Provider()
|
||||
provider: WakeControlProvider()
|
||||
) { value in
|
||||
ControlWidgetToggle(
|
||||
"Start Timer",
|
||||
isOn: value.isRunning,
|
||||
action: StartTimerIntent(value.name)
|
||||
) { isRunning in
|
||||
Label(isRunning ? "On" : "Off", systemImage: "timer")
|
||||
ControlWidgetButton(action: WakeDeviceIntent(deviceId: value.deviceId, deviceName: value.deviceName)) {
|
||||
Label(value.deviceName, systemImage: "bolt.fill")
|
||||
}
|
||||
}
|
||||
.displayName("Timer")
|
||||
.description("A an example control that runs a timer.")
|
||||
.displayName("Wake Device")
|
||||
.description("Quickly wake a device from Control Center.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Control Widget Value
|
||||
|
||||
extension widgetControl {
|
||||
struct Value {
|
||||
var isRunning: Bool
|
||||
var name: 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)
|
||||
}
|
||||
var deviceId: String
|
||||
var deviceName: String
|
||||
}
|
||||
}
|
||||
|
||||
struct TimerConfiguration: ControlConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "Timer Name Configuration"
|
||||
// MARK: - Control Widget Provider
|
||||
|
||||
@Parameter(title: "Timer Name", default: "Timer")
|
||||
var timerName: String
|
||||
}
|
||||
|
||||
struct StartTimerIntent: SetValueIntent {
|
||||
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
|
||||
struct WakeControlProvider: AppIntentControlValueProvider {
|
||||
func previewValue(configuration: WakeControlConfiguration) -> widgetControl.Value {
|
||||
widgetControl.Value(
|
||||
deviceId: configuration.device?.id ?? "",
|
||||
deviceName: configuration.device?.name ?? "Select Device"
|
||||
)
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
// Start the timer…
|
||||
return .result()
|
||||
func currentValue(configuration: WakeControlConfiguration) async throws -> widgetControl.Value {
|
||||
widgetControl.Value(
|
||||
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 => ({
|
||||
type: "widget",
|
||||
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 {
|
||||
// Export widgets here
|
||||
widget()
|
||||
widgetControl()
|
||||
WidgetLiveActivity()
|
||||
// widgetControl()
|
||||
}
|
||||
}
|
||||
|
||||
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 SwiftUI
|
||||
|
||||
struct Provider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> SimpleEntry {
|
||||
SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
|
||||
}
|
||||
// MARK: - Timeline Entry
|
||||
|
||||
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
|
||||
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 {
|
||||
struct DeviceEntry: TimelineEntry {
|
||||
let date: Date
|
||||
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
|
||||
|
||||
@Environment(\.widgetFamily) var widgetFamily
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Time:")
|
||||
Text(entry.date, style: .time)
|
||||
|
||||
Text("Favorite Emoji:")
|
||||
Text(entry.configuration.favoriteEmoji)
|
||||
if let device = entry.device {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Device Info Header
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(device.name)
|
||||
.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"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
|
||||
widgetEntryView(entry: entry)
|
||||
DeviceWidgetEntryView(entry: entry)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
.configurationDisplayName("Device Control")
|
||||
.description("Control your devices with wake, sleep, restart, and shutdown actions.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigurationAppIntent {
|
||||
fileprivate static var smiley: ConfigurationAppIntent {
|
||||
let intent = ConfigurationAppIntent()
|
||||
intent.favoriteEmoji = "😀"
|
||||
return intent
|
||||
}
|
||||
|
||||
fileprivate static var starEyes: ConfigurationAppIntent {
|
||||
let intent = ConfigurationAppIntent()
|
||||
intent.favoriteEmoji = "🤩"
|
||||
return intent
|
||||
}
|
||||
}
|
||||
// Keep legacy name for backwards compatibility with existing widget installations
|
||||
typealias widget = DeviceControlWidget
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview(as: .systemSmall) {
|
||||
widget()
|
||||
DeviceControlWidget()
|
||||
} timeline: {
|
||||
SimpleEntry(date: .now, configuration: .smiley)
|
||||
SimpleEntry(date: .now, configuration: .starEyes)
|
||||
DeviceEntry(
|
||||
date: .now,
|
||||
configuration: ConfigurationAppIntent(),
|
||||
device: DeviceInfo(id: "preview", name: "Gaming PC", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.100", status: "online")
|
||||
)
|
||||
}
|
||||
|
||||
#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")
|
||||
)
|
||||
}
|
||||
|
||||