Initial FeelAloud project
This commit is contained in:
92
Moment/AppIntent.swift
Normal file
92
Moment/AppIntent.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
import AppIntents
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
|
||||
enum MomentRatingValue: String, AppEnum {
|
||||
case veryGood = "Sehr gut"
|
||||
case good = "Gut"
|
||||
case middle = "Mittel"
|
||||
case bad = "Schlecht"
|
||||
case veryBad = "Sehr schlecht"
|
||||
|
||||
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Stimmung")
|
||||
|
||||
static var caseDisplayRepresentations: [MomentRatingValue: DisplayRepresentation] = [
|
||||
.veryGood: "Sehr gut",
|
||||
.good: "Gut",
|
||||
.middle: "Mittel",
|
||||
.bad: "Schlecht",
|
||||
.veryBad: "Sehr schlecht"
|
||||
]
|
||||
|
||||
var emoji: String {
|
||||
switch self {
|
||||
case .veryGood: "😄"
|
||||
case .good: "🙂"
|
||||
case .middle: "😐"
|
||||
case .bad: "🙁"
|
||||
case .veryBad: "😞"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TrackMomentIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Momentaufnahme speichern"
|
||||
static var description = IntentDescription("Speichert eine schnelle Stimmungsabfrage aus dem Homescreen-Widget.")
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Stimmung")
|
||||
var rating: MomentRatingValue
|
||||
|
||||
init() {
|
||||
rating = .middle
|
||||
}
|
||||
|
||||
init(rating: MomentRatingValue) {
|
||||
self.rating = rating
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
MomentWidgetStorage.enqueue(ratingRawValue: rating.rawValue)
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: Moment.kind)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
enum MomentWidgetStorage {
|
||||
static let appGroupIdentifier = "group.de.feelaloud"
|
||||
static let pendingSnapshotsKey = "pendingWidgetSnapshots"
|
||||
static let lastRatingKey = "lastWidgetSnapshotRating"
|
||||
static let lastRecordedAtKey = "lastWidgetSnapshotDate"
|
||||
|
||||
static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: appGroupIdentifier) ?? .standard
|
||||
}
|
||||
|
||||
static func enqueue(ratingRawValue: String) {
|
||||
let defaults = defaults
|
||||
var snapshots = pendingSnapshots(from: defaults)
|
||||
snapshots.append(PendingWidgetSnapshot(ratingRawValue: ratingRawValue, createdAt: .now))
|
||||
|
||||
if let data = try? JSONEncoder().encode(snapshots) {
|
||||
defaults.set(data, forKey: pendingSnapshotsKey)
|
||||
}
|
||||
|
||||
defaults.set(ratingRawValue, forKey: lastRatingKey)
|
||||
defaults.set(Date.now, forKey: lastRecordedAtKey)
|
||||
}
|
||||
|
||||
private static func pendingSnapshots(from defaults: UserDefaults) -> [PendingWidgetSnapshot] {
|
||||
guard let data = defaults.data(forKey: pendingSnapshotsKey),
|
||||
let snapshots = try? JSONDecoder().decode([PendingWidgetSnapshot].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
}
|
||||
|
||||
struct PendingWidgetSnapshot: Codable {
|
||||
var ratingRawValue: String
|
||||
var createdAt: Date
|
||||
}
|
||||
11
Moment/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
11
Moment/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
35
Moment/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
35
Moment/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
Moment/Assets.xcassets/Contents.json
Normal file
6
Moment/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
11
Moment/Info.plist
Normal file
11
Moment/Info.plist
Normal file
@@ -0,0 +1,11 @@
|
||||
<?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>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
134
Moment/Moment.swift
Normal file
134
Moment/Moment.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
import AppIntents
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct MomentProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> MomentEntry {
|
||||
MomentEntry(date: .now, lastRating: nil, lastRecordedAt: nil)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (MomentEntry) -> Void) {
|
||||
completion(currentEntry())
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<MomentEntry>) -> Void) {
|
||||
completion(Timeline(entries: [currentEntry()], policy: .never))
|
||||
}
|
||||
|
||||
private func currentEntry() -> MomentEntry {
|
||||
let defaults = MomentWidgetStorage.defaults
|
||||
let lastRating = defaults.string(forKey: MomentWidgetStorage.lastRatingKey)
|
||||
.flatMap(MomentRatingValue.init(rawValue:))
|
||||
let lastRecordedAt = defaults.object(forKey: MomentWidgetStorage.lastRecordedAtKey) as? Date
|
||||
|
||||
return MomentEntry(date: .now, lastRating: lastRating, lastRecordedAt: lastRecordedAt)
|
||||
}
|
||||
}
|
||||
|
||||
struct MomentEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let lastRating: MomentRatingValue?
|
||||
let lastRecordedAt: Date?
|
||||
}
|
||||
|
||||
struct MomentEntryView: View {
|
||||
let entry: MomentEntry
|
||||
|
||||
private let ratings: [MomentRatingValue] = [
|
||||
.veryGood,
|
||||
.good,
|
||||
.middle,
|
||||
.bad,
|
||||
.veryBad
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Wie geht es dir?")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.82)
|
||||
.shadow(color: .black.opacity(0.12), radius: 2, y: 1)
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
if showsSavedState {
|
||||
Text("Gespeichert")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 7) {
|
||||
ForEach(ratings, id: \.self) { rating in
|
||||
Button(intent: TrackMomentIntent(rating: rating)) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Text(rating.emoji)
|
||||
.font(.system(size: entry.lastRating == rating ? 28 : 25, weight: .bold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 42)
|
||||
.background(buttonBackground(for: rating), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
if showsSavedState, entry.lastRating == rating {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.white, Color.green)
|
||||
.offset(x: 4, y: -4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(rating.rawValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.containerBackground(for: .widget) {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.82, green: 0.90, blue: 0.76),
|
||||
Color(red: 0.66, green: 0.78, blue: 0.60)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var showsSavedState: Bool {
|
||||
guard let lastRecordedAt = entry.lastRecordedAt else { return false }
|
||||
return Date.now.timeIntervalSince(lastRecordedAt) < 90
|
||||
}
|
||||
|
||||
private func buttonBackground(for rating: MomentRatingValue) -> Color {
|
||||
guard showsSavedState, entry.lastRating == rating else {
|
||||
return .white.opacity(0.68)
|
||||
}
|
||||
|
||||
return .white.opacity(0.92)
|
||||
}
|
||||
}
|
||||
|
||||
struct Moment: Widget {
|
||||
nonisolated static let kind = "Moment"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: Self.kind, provider: MomentProvider()) { entry in
|
||||
MomentEntryView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Momentaufnahme")
|
||||
.description("Tracke deine Stimmung direkt vom Homescreen.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
#Preview(as: .systemSmall) {
|
||||
Moment()
|
||||
} timeline: {
|
||||
MomentEntry(date: .now, lastRating: .good, lastRecordedAt: .now)
|
||||
}
|
||||
16
Moment/MomentBundle.swift
Normal file
16
Moment/MomentBundle.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// MomentBundle.swift
|
||||
// Moment
|
||||
//
|
||||
// Created by Tom Misch on 22.07.26.
|
||||
//
|
||||
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MomentBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
Moment()
|
||||
}
|
||||
}
|
||||
77
Moment/MomentControl.swift
Normal file
77
Moment/MomentControl.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// MomentControl.swift
|
||||
// Moment
|
||||
//
|
||||
// Created by Tom Misch on 22.07.26.
|
||||
//
|
||||
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct MomentControl: ControlWidget {
|
||||
static let kind: String = "de.feelaloud.Moment"
|
||||
|
||||
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 MomentControl {
|
||||
struct Value {
|
||||
var isRunning: Bool
|
||||
var name: String
|
||||
}
|
||||
|
||||
struct Provider: AppIntentControlValueProvider {
|
||||
func previewValue(configuration: TimerConfiguration) -> Value {
|
||||
MomentControl.Value(isRunning: false, name: configuration.timerName)
|
||||
}
|
||||
|
||||
func currentValue(configuration: TimerConfiguration) async throws -> Value {
|
||||
let isRunning = true // Check if the timer is running
|
||||
return MomentControl.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()
|
||||
}
|
||||
}
|
||||
80
Moment/MomentLiveActivity.swift
Normal file
80
Moment/MomentLiveActivity.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// MomentLiveActivity.swift
|
||||
// Moment
|
||||
//
|
||||
// Created by Tom Misch on 22.07.26.
|
||||
//
|
||||
|
||||
import ActivityKit
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
struct MomentAttributes: 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 MomentLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: MomentAttributes.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: "http://www.apple.com"))
|
||||
.keylineTint(Color.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MomentAttributes {
|
||||
fileprivate static var preview: MomentAttributes {
|
||||
MomentAttributes(name: "World")
|
||||
}
|
||||
}
|
||||
|
||||
extension MomentAttributes.ContentState {
|
||||
fileprivate static var smiley: MomentAttributes.ContentState {
|
||||
MomentAttributes.ContentState(emoji: "😀")
|
||||
}
|
||||
|
||||
fileprivate static var starEyes: MomentAttributes.ContentState {
|
||||
MomentAttributes.ContentState(emoji: "🤩")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Notification", as: .content, using: MomentAttributes.preview) {
|
||||
MomentLiveActivity()
|
||||
} contentStates: {
|
||||
MomentAttributes.ContentState.smiley
|
||||
MomentAttributes.ContentState.starEyes
|
||||
}
|
||||
Reference in New Issue
Block a user