Initial commit: EzTimer iOS app with widget extension

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 22:10:34 -05:00
commit 99e7e79347
73 changed files with 2447 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
//
// AppIntent.swift
// EzTimerWidget
//
// Created by Jared Evans on 12/17/25.
//
import WidgetKit
import AppIntents
struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Configuration" }
static var description: IntentDescription { "This is an example widget." }
// An example configurable parameter.
@Parameter(title: "Favorite Emoji", default: "😃")
var favoriteEmoji: String
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1 @@
{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","idiom":"ipad","scale":"2x"}]}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,88 @@
//
// EzTimerWidget.swift
// EzTimerWidget
//
// Created by Jared Evans on 12/17/25.
//
import WidgetKit
import SwiftUI
struct Provider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
}
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 {
let date: Date
let configuration: ConfigurationAppIntent
}
struct EzTimerWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
VStack {
Text("Time:")
Text(entry.date, style: .time)
Text("Favorite Emoji:")
Text(entry.configuration.favoriteEmoji)
}
}
}
struct EzTimerWidget: Widget {
let kind: String = "EzTimerWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
EzTimerWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
}
}
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
}
}
#Preview(as: .systemSmall) {
EzTimerWidget()
} timeline: {
SimpleEntry(date: .now, configuration: .smiley)
SimpleEntry(date: .now, configuration: .starEyes)
}

View File

@@ -0,0 +1,18 @@
//
// EzTimerWidgetBundle.swift
// EzTimerWidget
//
// Created by Jared Evans on 12/17/25.
//
import WidgetKit
import SwiftUI
@main
struct EzTimerWidgetBundle: WidgetBundle {
var body: some Widget {
// EzTimerWidget()
// EzTimerWidgetControl()
EzTimerWidgetLiveActivity()
}
}

View File

@@ -0,0 +1,77 @@
//
// EzTimerWidgetControl.swift
// EzTimerWidget
//
// Created by Jared Evans on 12/17/25.
//
import AppIntents
import SwiftUI
import WidgetKit
struct EzTimerWidgetControl: ControlWidget {
static let kind: String = "com.jaredlog.EzTimer.EzTimerWidget"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
provider: Provider()
) { value in
ControlWidgetToggle(
"Start Timer",
isOn: value.isRunning,
action: StartTimerIntent(value.name)
) { isRunning in
Label(isRunning ? "On" : "Off", systemImage: "timer")
}
}
.displayName("Timer")
.description("A an example control that runs a timer.")
}
}
extension EzTimerWidgetControl {
struct Value {
var isRunning: Bool
var name: String
}
struct Provider: AppIntentControlValueProvider {
func previewValue(configuration: TimerConfiguration) -> Value {
EzTimerWidgetControl.Value(isRunning: false, name: configuration.timerName)
}
func currentValue(configuration: TimerConfiguration) async throws -> Value {
let isRunning = true // Check if the timer is running
return EzTimerWidgetControl.Value(isRunning: isRunning, name: configuration.timerName)
}
}
}
struct TimerConfiguration: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Timer Name Configuration"
@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
}
func perform() async throws -> some IntentResult {
// Start the timer
return .result()
}
}

View File

@@ -0,0 +1,107 @@
//
// EzTimerWidgetLiveActivity.swift
// EzTimerWidget
//
// Created by Jared Evans on 12/17/25.
//
import ActivityKit
import WidgetKit
import SwiftUI
struct EzTimerWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TimerAttributes.self) { context in
// Lock Screen/Banner UI
VStack(alignment: .leading) {
HStack {
Image(systemName: "timer")
.foregroundColor(.red)
Text("VizTimer")
.font(.headline)
.foregroundColor(.white)
}
HStack {
// This counts down automatically relative to the target date
if context.state.isFinished {
Text("Done at \(context.state.endTime, style: .time)")
.font(.system(size: 40, weight: .bold)) // Slightly smaller to fit time
.foregroundColor(.white)
} else {
Text(timerInterval: Date()...context.state.endTime, countsDown: true)
.font(.system(size: 48, weight: .bold))
.monospacedDigit()
.foregroundColor(.white)
}
Spacer()
if !context.state.message.isEmpty {
Text(context.state.message)
.font(.subheadline)
.foregroundColor(.white)
.multilineTextAlignment(.trailing)
.lineLimit(2)
}
}
}
.padding()
.activityBackgroundTint(Color.black.opacity(0.8))
.activitySystemActionForegroundColor(Color.red)
} dynamicIsland: { context in
DynamicIsland {
// Expanded State
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "timer")
.foregroundColor(.red)
}
DynamicIslandExpandedRegion(.trailing) {
if context.state.isFinished {
Text("Done at \(context.state.endTime, style: .time)")
.font(.headline)
.fontWeight(.bold)
.foregroundColor(.red)
} else {
Text(timerInterval: Date()...context.state.endTime, countsDown: true)
.monospacedDigit()
.font(.title2)
}
}
DynamicIslandExpandedRegion(.bottom) {
if !context.state.message.isEmpty {
Text(context.state.message)
.font(.caption)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
.padding(.top, 4)
} else {
Text("Time Remaining")
.font(.caption)
.foregroundColor(.gray)
}
}
} compactLeading: {
Image(systemName: "timer")
.foregroundColor(.red)
} compactTrailing: {
if context.state.isFinished {
Text("End")
.fontWeight(.bold)
.frame(minWidth: 40)
} else {
Text(timerInterval: Date()...context.state.endTime, countsDown: true)
.monospacedDigit()
.frame(minWidth: 40)
}
} minimal: {
Image(systemName: "timer")
.foregroundColor(.red)
}
.widgetURL(URL(string: "viztimer://open"))
.keylineTint(Color.red)
}
}
}

15
EzTimerWidget/Info.plist Normal file
View File

@@ -0,0 +1,15 @@
<?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>CFBundleVersion</key>
<string>2</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>