WIP: local handoff implementation

Local work on the social.io handoff before merging the claude
worktree branch. Includes the full per-spec Sources/Core/Design
module (8 files), watchOS target under WatchApp/, Live Activity +
widget extension, entitlements, scheme, and asset catalog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 16:26:38 +02:00
parent 15af566353
commit 2fe6b8a6df
32 changed files with 3861 additions and 926 deletions

View File

@@ -5,9 +5,19 @@
"derivedDataPath": "swift/.build/xcode-derived-data", "derivedDataPath": "swift/.build/xcode-derived-data",
"parallelBuilds": true, "parallelBuilds": true,
"parallelTests": false, "parallelTests": false,
"buildPlatforms": ["macos", "ios", "ipad"], "buildPlatforms": [
"testPlatforms": ["macos"], "macos",
"watchPlatforms": ["macos", "ios", "ipad"], "ios",
"ipad"
],
"testPlatforms": [
"macos"
],
"watchPlatforms": [
"macos",
"ios",
"ipad"
],
"watchDebounceMs": 1200, "watchDebounceMs": 1200,
"defaultPlatform": "macos", "defaultPlatform": "macos",
"app": { "app": {
@@ -23,7 +33,11 @@
"screenshots": { "screenshots": {
"scenariosFile": "swift/Automation/ui-screenshot-routes.txt", "scenariosFile": "swift/Automation/ui-screenshot-routes.txt",
"outputDir": "/tmp/socialio-ui-review", "outputDir": "/tmp/socialio-ui-review",
"reviewPlatforms": ["ios", "ipad", "macos"], "reviewPlatforms": [
"ios",
"ipad",
"macos"
],
"launchDelayMs": 2000, "launchDelayMs": 2000,
"stepDelayMs": 1200, "stepDelayMs": 1200,
"initialCommand": "socialio://mailbox/inbox", "initialCommand": "socialio://mailbox/inbox",
@@ -36,12 +50,14 @@
}, },
"targets": { "targets": {
"ios": { "ios": {
"simulatorName": "iPhone Air", "simulatorName": "iPhone 17 Pro",
"runtime": "latest" "runtime": "26.4",
"simulatorUDID": "8EBCDD58-34AB-457A-A878-8004A6108CA9"
}, },
"ipad": { "ipad": {
"simulatorName": "iPad mini (A17 Pro)", "simulatorName": "iPad Pro 11-inch (M5)",
"runtime": "latest" "runtime": "26.4",
"simulatorUDID": "4ED4902E-C8EB-444A-A8FF-183ABC08E8C2"
} }
} }
} }

View File

@@ -0,0 +1,8 @@
{
"images" : [
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.420",
"red" : "0.184"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.510",
"red" : "0.312"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.039",
"green" : "0.624",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.206",
"green" : "0.706",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.345",
"green" : "0.819",
"red" : "0.188"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.470",
"green" : "0.900",
"red" : "0.320"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.420",
"red" : "0.184"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.510",
"red" : "0.312"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,8 @@
<?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.developer.activitykit</key>
<true/>
</dict>
</plist>

View File

@@ -16,6 +16,31 @@
A10000000000000000000007 /* AppControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* AppControlService.swift */; }; A10000000000000000000007 /* AppControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* AppControlService.swift */; };
A10000000000000000000008 /* AppNavigationCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* AppNavigationCommandTests.swift */; }; A10000000000000000000008 /* AppNavigationCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* AppNavigationCommandTests.swift */; };
A10000000000000000000009 /* AppViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000A /* AppViewModelTests.swift */; }; A10000000000000000000009 /* AppViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000A /* AppViewModelTests.swift */; };
A1000000000000000000000A /* SIOTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000C /* SIOTokens.swift */; };
A1000000000000000000000B /* ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000D /* ButtonStyles.swift */; };
A1000000000000000000000C /* GlassChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000E /* GlassChrome.swift */; };
A1000000000000000000000D /* LaneChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000F /* LaneChip.swift */; };
A1000000000000000000000E /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000010 /* AvatarView.swift */; };
A1000000000000000000000F /* AISummaryCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000011 /* AISummaryCard.swift */; };
A10000000000000000000010 /* KeyboardHint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000012 /* KeyboardHint.swift */; };
A10000000000000000000011 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000013 /* Haptics.swift */; };
A10000000000000000000012 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000014 /* Assets.xcassets */; };
A10000000000000000000013 /* SocialIOWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000015 /* SocialIOWatchApp.swift */; };
A10000000000000000000014 /* WatchInboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000016 /* WatchInboxView.swift */; };
A10000000000000000000015 /* SIOTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000017 /* Watch SIOTokens.swift */; };
A10000000000000000000016 /* MailModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* MailModels.swift */; };
A10000000000000000000017 /* MockMailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* MockMailService.swift */; };
A10000000000000000000018 /* SocialIOWatchWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000018 /* SocialIOWatchWidgets.swift */; };
A10000000000000000000019 /* SIOTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000017 /* Watch SIOTokens.swift */; };
A1000000000000000000001A /* MailModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* MailModels.swift */; };
A1000000000000000000001B /* MockMailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* MockMailService.swift */; };
A1000000000000000000001C /* MailNotificationActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001B /* MailNotificationActivity.swift */; };
A1000000000000000000001D /* MailNotificationActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001B /* MailNotificationActivity.swift */; };
A1000000000000000000001E /* MailNotificationWidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001D /* MailNotificationWidgetExtension.swift */; };
A1000000000000000000001F /* SIOTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000C /* SIOTokens.swift */; };
A10000000000000000000020 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000014 /* Assets.xcassets */; };
A10000000000000000000021 /* SocialIOWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001C /* SocialIOWidgets.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
A10000000000000000000022 /* SocialIOWatchWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001A /* SocialIOWatchWidgets.appex */; platformFilter = watchos; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -26,8 +51,47 @@
remoteGlobalIDString = A50000000000000000000001; remoteGlobalIDString = A50000000000000000000001;
remoteInfo = SocialIO; remoteInfo = SocialIO;
}; };
A90000000000000000000003 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A60000000000000000000001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = A50000000000000000000005;
remoteInfo = SocialIOWidgets;
};
A90000000000000000000004 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A60000000000000000000001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = A50000000000000000000004;
remoteInfo = SocialIOWatchWidgets;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
A30000000000000000000010 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
A10000000000000000000021 /* SocialIOWidgets.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
A30000000000000000000011 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
A10000000000000000000022 /* SocialIOWatchWidgets.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
A20000000000000000000001 /* SocialIOApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialIOApp.swift; sourceTree = "<group>"; }; A20000000000000000000001 /* SocialIOApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialIOApp.swift; sourceTree = "<group>"; };
A20000000000000000000002 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = "<group>"; }; A20000000000000000000002 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = "<group>"; };
@@ -40,6 +104,24 @@
A20000000000000000000009 /* AppNavigationCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCommandTests.swift; sourceTree = "<group>"; }; A20000000000000000000009 /* AppNavigationCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCommandTests.swift; sourceTree = "<group>"; };
A2000000000000000000000A /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = "<group>"; }; A2000000000000000000000A /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = "<group>"; };
A2000000000000000000000B /* SocialIOTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SocialIOTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A2000000000000000000000B /* SocialIOTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SocialIOTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
A2000000000000000000000C /* SIOTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SIOTokens.swift; sourceTree = "<group>"; };
A2000000000000000000000D /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = "<group>"; };
A2000000000000000000000E /* GlassChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassChrome.swift; sourceTree = "<group>"; };
A2000000000000000000000F /* LaneChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaneChip.swift; sourceTree = "<group>"; };
A20000000000000000000010 /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = "<group>"; };
A20000000000000000000011 /* AISummaryCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISummaryCard.swift; sourceTree = "<group>"; };
A20000000000000000000012 /* KeyboardHint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardHint.swift; sourceTree = "<group>"; };
A20000000000000000000013 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = "<group>"; };
A20000000000000000000014 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A20000000000000000000015 /* SocialIOWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialIOWatchApp.swift; sourceTree = "<group>"; };
A20000000000000000000016 /* WatchInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchInboxView.swift; sourceTree = "<group>"; };
A20000000000000000000017 /* Watch SIOTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SIOTokens.swift; sourceTree = "<group>"; };
A20000000000000000000018 /* SocialIOWatchWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialIOWatchWidgets.swift; sourceTree = "<group>"; };
A20000000000000000000019 /* SocialIOWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SocialIOWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
A2000000000000000000001A /* SocialIOWatchWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SocialIOWatchWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
A2000000000000000000001B /* MailNotificationActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailNotificationActivity.swift; sourceTree = "<group>"; };
A2000000000000000000001C /* SocialIOWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SocialIOWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
A2000000000000000000001D /* MailNotificationWidgetExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailNotificationWidgetExtension.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -57,6 +139,27 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A30000000000000000000007 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A30000000000000000000008 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A3000000000000000000000D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -71,6 +174,8 @@
A40000000000000000000002 /* SocialIO */ = { A40000000000000000000002 /* SocialIO */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A20000000000000000000014 /* Assets.xcassets */,
A4000000000000000000000D /* WatchApp */,
A40000000000000000000003 /* Sources */, A40000000000000000000003 /* Sources */,
A4000000000000000000000B /* Tests */, A4000000000000000000000B /* Tests */,
); );
@@ -92,6 +197,8 @@
children = ( children = (
A20000000000000000000001 /* SocialIOApp.swift */, A20000000000000000000001 /* SocialIOApp.swift */,
A20000000000000000000002 /* AppViewModel.swift */, A20000000000000000000002 /* AppViewModel.swift */,
A2000000000000000000001B /* MailNotificationActivity.swift */,
A2000000000000000000001D /* MailNotificationWidgetExtension.swift */,
A20000000000000000000007 /* AppNavigationCommand.swift */, A20000000000000000000007 /* AppNavigationCommand.swift */,
A20000000000000000000008 /* AppControlService.swift */, A20000000000000000000008 /* AppControlService.swift */,
); );
@@ -101,6 +208,7 @@
A40000000000000000000005 /* Core */ = { A40000000000000000000005 /* Core */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A4000000000000000000000C /* Design */,
A40000000000000000000006 /* Models */, A40000000000000000000006 /* Models */,
A40000000000000000000007 /* Services */, A40000000000000000000007 /* Services */,
); );
@@ -136,6 +244,9 @@
children = ( children = (
A20000000000000000000006 /* SocialIO.app */, A20000000000000000000006 /* SocialIO.app */,
A2000000000000000000000B /* SocialIOTests.xctest */, A2000000000000000000000B /* SocialIOTests.xctest */,
A2000000000000000000001C /* SocialIOWidgets.appex */,
A20000000000000000000019 /* SocialIOWatch.app */,
A2000000000000000000001A /* SocialIOWatchWidgets.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -157,6 +268,48 @@
path = Tests; path = Tests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A4000000000000000000000C /* Design */ = {
isa = PBXGroup;
children = (
A2000000000000000000000C /* SIOTokens.swift */,
A2000000000000000000000D /* ButtonStyles.swift */,
A2000000000000000000000E /* GlassChrome.swift */,
A2000000000000000000000F /* LaneChip.swift */,
A20000000000000000000010 /* AvatarView.swift */,
A20000000000000000000011 /* AISummaryCard.swift */,
A20000000000000000000012 /* KeyboardHint.swift */,
A20000000000000000000013 /* Haptics.swift */,
);
path = Design;
sourceTree = "<group>";
};
A4000000000000000000000D /* WatchApp */ = {
isa = PBXGroup;
children = (
A20000000000000000000015 /* SocialIOWatchApp.swift */,
A20000000000000000000016 /* WatchInboxView.swift */,
A4000000000000000000000E /* Design */,
A4000000000000000000000F /* Widgets */,
);
path = WatchApp;
sourceTree = "<group>";
};
A4000000000000000000000E /* Design */ = {
isa = PBXGroup;
children = (
A20000000000000000000017 /* Watch SIOTokens.swift */,
);
path = Design;
sourceTree = "<group>";
};
A4000000000000000000000F /* Widgets */ = {
isa = PBXGroup;
children = (
A20000000000000000000018 /* SocialIOWatchWidgets.swift */,
);
path = Widgets;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -166,6 +319,7 @@
buildPhases = ( buildPhases = (
A30000000000000000000002 /* Sources */, A30000000000000000000002 /* Sources */,
A30000000000000000000001 /* Frameworks */, A30000000000000000000001 /* Frameworks */,
A30000000000000000000010 /* Embed App Extensions */,
A30000000000000000000003 /* Resources */, A30000000000000000000003 /* Resources */,
); );
buildRules = ( buildRules = (
@@ -195,6 +349,59 @@
productReference = A2000000000000000000000B /* SocialIOTests.xctest */; productReference = A2000000000000000000000B /* SocialIOTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test"; productType = "com.apple.product-type.bundle.unit-test";
}; };
A50000000000000000000003 /* SocialIOWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = A70000000000000000000004 /* Build configuration list for PBXNativeTarget "SocialIOWatch" */;
buildPhases = (
A30000000000000000000009 /* Sources */,
A30000000000000000000007 /* Frameworks */,
A30000000000000000000011 /* Embed App Extensions */,
A3000000000000000000000B /* Resources */,
);
buildRules = (
);
dependencies = (
A90000000000000000000006 /* PBXTargetDependency */,
);
name = SocialIOWatch;
productName = SocialIOWatch;
productReference = A20000000000000000000019 /* SocialIOWatch.app */;
productType = "com.apple.product-type.application";
};
A50000000000000000000004 /* SocialIOWatchWidgets */ = {
isa = PBXNativeTarget;
buildConfigurationList = A70000000000000000000005 /* Build configuration list for PBXNativeTarget "SocialIOWatchWidgets" */;
buildPhases = (
A3000000000000000000000A /* Sources */,
A30000000000000000000008 /* Frameworks */,
A3000000000000000000000C /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = SocialIOWatchWidgets;
productName = SocialIOWatchWidgets;
productReference = A2000000000000000000001A /* SocialIOWatchWidgets.appex */;
productType = "com.apple.product-type.app-extension";
};
A50000000000000000000005 /* SocialIOWidgets */ = {
isa = PBXNativeTarget;
buildConfigurationList = A70000000000000000000006 /* Build configuration list for PBXNativeTarget "SocialIOWidgets" */;
buildPhases = (
A3000000000000000000000F /* Sources */,
A3000000000000000000000D /* Frameworks */,
A3000000000000000000000E /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = SocialIOWidgets;
productName = SocialIOWidgets;
productReference = A2000000000000000000001C /* SocialIOWidgets.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -212,6 +419,15 @@
CreatedOnToolsVersion = 26.0; CreatedOnToolsVersion = 26.0;
TestTargetID = A50000000000000000000001; TestTargetID = A50000000000000000000001;
}; };
A50000000000000000000003 = {
CreatedOnToolsVersion = 26.0;
};
A50000000000000000000004 = {
CreatedOnToolsVersion = 26.0;
};
A50000000000000000000005 = {
CreatedOnToolsVersion = 26.0;
};
}; };
}; };
buildConfigurationList = A70000000000000000000001 /* Build configuration list for PBXProject "SocialIO" */; buildConfigurationList = A70000000000000000000001 /* Build configuration list for PBXProject "SocialIO" */;
@@ -229,6 +445,9 @@
targets = ( targets = (
A50000000000000000000001 /* SocialIO */, A50000000000000000000001 /* SocialIO */,
A50000000000000000000002 /* SocialIOTests */, A50000000000000000000002 /* SocialIOTests */,
A50000000000000000000005 /* SocialIOWidgets */,
A50000000000000000000003 /* SocialIOWatch */,
A50000000000000000000004 /* SocialIOWatchWidgets */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -238,6 +457,7 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A10000000000000000000012 /* Assets.xcassets in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -248,6 +468,28 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A3000000000000000000000B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A3000000000000000000000C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A3000000000000000000000E /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A10000000000000000000020 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -255,10 +497,19 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A1000000000000000000000F /* AISummaryCard.swift in Sources */,
A10000000000000000000002 /* AppViewModel.swift in Sources */, A10000000000000000000002 /* AppViewModel.swift in Sources */,
A1000000000000000000001C /* MailNotificationActivity.swift in Sources */,
A1000000000000000000000E /* AvatarView.swift in Sources */,
A1000000000000000000000B /* ButtonStyles.swift in Sources */,
A1000000000000000000000C /* GlassChrome.swift in Sources */,
A10000000000000000000011 /* Haptics.swift in Sources */,
A10000000000000000000010 /* KeyboardHint.swift in Sources */,
A1000000000000000000000D /* LaneChip.swift in Sources */,
A10000000000000000000005 /* MailRootView.swift in Sources */, A10000000000000000000005 /* MailRootView.swift in Sources */,
A10000000000000000000003 /* MailModels.swift in Sources */, A10000000000000000000003 /* MailModels.swift in Sources */,
A10000000000000000000004 /* MockMailService.swift in Sources */, A10000000000000000000004 /* MockMailService.swift in Sources */,
A1000000000000000000000A /* SIOTokens.swift in Sources */,
A10000000000000000000001 /* SocialIOApp.swift in Sources */, A10000000000000000000001 /* SocialIOApp.swift in Sources */,
A10000000000000000000006 /* AppNavigationCommand.swift in Sources */, A10000000000000000000006 /* AppNavigationCommand.swift in Sources */,
A10000000000000000000007 /* AppControlService.swift in Sources */, A10000000000000000000007 /* AppControlService.swift in Sources */,
@@ -274,6 +525,39 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A30000000000000000000009 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A10000000000000000000016 /* MailModels.swift in Sources */,
A10000000000000000000017 /* MockMailService.swift in Sources */,
A10000000000000000000015 /* SIOTokens.swift in Sources */,
A10000000000000000000013 /* SocialIOWatchApp.swift in Sources */,
A10000000000000000000014 /* WatchInboxView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
A3000000000000000000000A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1000000000000000000001A /* MailModels.swift in Sources */,
A1000000000000000000001B /* MockMailService.swift in Sources */,
A10000000000000000000019 /* SIOTokens.swift in Sources */,
A10000000000000000000018 /* SocialIOWatchWidgets.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
A3000000000000000000000F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1000000000000000000001D /* MailNotificationActivity.swift in Sources */,
A1000000000000000000001E /* MailNotificationWidgetExtension.swift in Sources */,
A1000000000000000000001F /* SIOTokens.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
@@ -282,6 +566,16 @@
target = A50000000000000000000001 /* SocialIO */; target = A50000000000000000000001 /* SocialIO */;
targetProxy = A90000000000000000000001 /* PBXContainerItemProxy */; targetProxy = A90000000000000000000001 /* PBXContainerItemProxy */;
}; };
A90000000000000000000005 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A50000000000000000000005 /* SocialIOWidgets */;
targetProxy = A90000000000000000000003 /* PBXContainerItemProxy */;
};
A90000000000000000000006 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A50000000000000000000004 /* SocialIOWatchWidgets */;
targetProxy = A90000000000000000000004 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@@ -304,8 +598,8 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
SDKROOT = auto; SDKROOT = auto;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
}; };
@@ -331,8 +625,8 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
SDKROOT = auto; SDKROOT = auto;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
@@ -344,7 +638,9 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
"CODE_SIGN_ENTITLEMENTS[sdk=iphone*]" = SocialIO.entitlements;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
@@ -369,6 +665,8 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 0.1.0; MARKETING_VERSION = 0.1.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app; PRODUCT_BUNDLE_IDENTIFIER = io.social.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -384,7 +682,9 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
"CODE_SIGN_ENTITLEMENTS[sdk=iphone*]" = SocialIO.entitlements;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -408,6 +708,8 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 0.1.0; MARKETING_VERSION = 0.1.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app; PRODUCT_BUNDLE_IDENTIFIER = io.social.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -432,7 +734,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
"@loader_path/../Frameworks", "@loader_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 26.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.tests; PRODUCT_BUNDLE_IDENTIFIER = io.social.app.tests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -454,7 +756,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
"@loader_path/../Frameworks", "@loader_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 26.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.tests; PRODUCT_BUNDLE_IDENTIFIER = io.social.app.tests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -464,6 +766,154 @@
}; };
name = Release; name = Release;
}; };
A80000000000000000000007 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGNING_ALLOWED = NO;
CODE_SIGNING_REQUIRED = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "social.io";
INFOPLIST_KEY_CFBundleName = "social.io";
INFOPLIST_KEY_WKApplication = YES;
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.watch;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Debug;
};
A80000000000000000000008 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGNING_ALLOWED = NO;
CODE_SIGNING_REQUIRED = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "social.io";
INFOPLIST_KEY_CFBundleName = "social.io";
INFOPLIST_KEY_WKApplication = YES;
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.watch;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Release;
};
A80000000000000000000009 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGNING_ALLOWED = NO;
CODE_SIGNING_REQUIRED = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = WatchApp/Widgets/SocialIOWatchWidgets-Info.plist;
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.watch.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Debug;
};
A8000000000000000000000A /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGNING_ALLOWED = NO;
CODE_SIGNING_REQUIRED = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = WatchApp/Widgets/SocialIOWatchWidgets-Info.plist;
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.watch.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Release;
};
A8000000000000000000000B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGNING_ALLOWED = NO;
CODE_SIGNING_REQUIRED = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Sources/App/SocialIOWidgets-Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBSERVATION_ENABLED = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A8000000000000000000000C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGNING_ALLOWED = NO;
CODE_SIGNING_REQUIRED = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Sources/App/SocialIOWidgets-Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBSERVATION_ENABLED = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -494,6 +944,33 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
A70000000000000000000004 /* Build configuration list for PBXNativeTarget "SocialIOWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A80000000000000000000007 /* Debug */,
A80000000000000000000008 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A70000000000000000000005 /* Build configuration list for PBXNativeTarget "SocialIOWatchWidgets" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A80000000000000000000009 /* Debug */,
A8000000000000000000000A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A70000000000000000000006 /* Build configuration list for PBXNativeTarget "SocialIOWidgets" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A8000000000000000000000B /* Debug */,
A8000000000000000000000C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = A60000000000000000000001 /* Project object */; rootObject = A60000000000000000000001 /* Project object */;

View File

@@ -20,6 +20,48 @@
ReferencedContainer = "container:SocialIO.xcodeproj"> ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
<BuildActionEntry
buildForTesting = "NO"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A50000000000000000000005"
BuildableName = "SocialIOWidgets.appex"
BlueprintName = "SocialIOWidgets"
ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "NO"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A50000000000000000000003"
BuildableName = "SocialIOWatch.app"
BlueprintName = "SocialIOWatch"
ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "NO"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A50000000000000000000004"
BuildableName = "SocialIOWatchWidgets.appex"
BlueprintName = "SocialIOWatchWidgets"
ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
buildForRunning = "NO" buildForRunning = "NO"

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "NO"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A50000000000000000000003"
BuildableName = "SocialIOWatch.app"
BlueprintName = "SocialIOWatch"
ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "NO"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A50000000000000000000004"
BuildableName = "SocialIOWatchWidgets.appex"
BlueprintName = "SocialIOWatchWidgets"
ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A50000000000000000000003"
BuildableName = "SocialIOWatch.app"
BlueprintName = "SocialIOWatch"
ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A50000000000000000000003"
BuildableName = "SocialIOWatch.app"
BlueprintName = "SocialIOWatch"
ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -9,19 +9,24 @@ final class AppViewModel {
var focusedMessageRouteID: String? var focusedMessageRouteID: String?
var searchText = "" var searchText = ""
var showUnreadOnly = false var showUnreadOnly = false
var laneFilter: Lane?
var isComposing = false var isComposing = false
var composeDraft = ComposeDraft() var isCommandPalettePresented = false
var composeDraft = ComposeDraft(from: "phil@social.io")
var threads: [MailThread] = [] var threads: [MailThread] = []
var isLoading = false var isLoading = false
var isSending = false var isSending = false
var errorMessage: String? var errorMessage: String?
var mailboxNavigationToken = UUID() var mailboxNavigationToken = UUID()
var threadNavigationToken = UUID() var threadNavigationToken = UUID()
let currentUser = MailPerson(name: "Phil Kunz", email: "phil@social.io")
private let service: MailServicing private let service: MailServicing
private let controlService: AppControlServicing private let controlService: AppControlServicing
private var pendingNavigationCommand: AppNavigationCommand? private var pendingNavigationCommand: AppNavigationCommand?
private var isListeningForBackendCommands = false private var isListeningForBackendCommands = false
@ObservationIgnored private var composeAutosaveTask: Task<Void, Never>?
@ObservationIgnored private let autosaveKey = "sio.compose.autosave"
init( init(
service: MailServicing = MockMailService(), service: MailServicing = MockMailService(),
@@ -29,6 +34,7 @@ final class AppViewModel {
) { ) {
self.service = service self.service = service
self.controlService = controlService self.controlService = controlService
restoreAutosavedDraft()
if let command = AppNavigationCommand.from(environment: ProcessInfo.processInfo.environment) { if let command = AppNavigationCommand.from(environment: ProcessInfo.processInfo.environment) {
apply(command: command) apply(command: command)
} }
@@ -40,9 +46,9 @@ final class AppViewModel {
} }
var filteredThreads: [MailThread] { var filteredThreads: [MailThread] {
threads baseThreads(for: selectedMailbox)
.filter { thread in .filter { thread in
selectedMailbox == .starred ? thread.isStarred : thread.mailbox == selectedMailbox laneFilter == nil || thread.lane == laneFilter
} }
.filter { thread in .filter { thread in
!showUnreadOnly || thread.isUnread !showUnreadOnly || thread.isUnread
@@ -56,18 +62,92 @@ final class AppViewModel {
} }
func threadCount(in mailbox: Mailbox) -> Int { func threadCount(in mailbox: Mailbox) -> Int {
threads.filter { thread in baseThreads(for: mailbox).count
mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox
}
.count
} }
func unreadCount(in mailbox: Mailbox) -> Int { func unreadCount(in mailbox: Mailbox) -> Int {
threads.filter { thread in baseThreads(for: mailbox)
let matchesMailbox = mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox .filter { $0.isUnread }
return matchesMailbox && thread.isUnread .count
}
func laneCount(_ lane: Lane?, in mailbox: Mailbox? = nil) -> Int {
baseThreads(for: mailbox ?? selectedMailbox)
.filter { lane == nil || $0.lane == lane }
.count
}
func unreadCount(for lane: Lane?) -> Int {
baseThreads(for: selectedMailbox)
.filter { thread in
thread.isUnread && (lane == nil || thread.lane == lane)
}
.count
}
var screenerThreads: [MailThread] {
baseThreads(for: .screener)
.sorted { $0.lastUpdated > $1.lastUpdated }
}
var searchResults: [MailThread] {
threads
.filter(matchesSearch)
.sorted { lhs, rhs in
let lhsScore = score(for: lhs)
let rhsScore = score(for: rhs)
if lhsScore == rhsScore {
return lhs.lastUpdated > rhs.lastUpdated
}
return lhsScore > rhsScore
}
}
var folderNames: [String] {
Array(Set(threads.flatMap(\.tags)))
.filter { !$0.isEmpty && !["Draft", "Launch", "Search", "System", "External", "Sent"].contains($0) }
.sorted()
}
var topSearchResult: MailThread? {
searchResults.first
}
var remainingSearchResults: [MailThread] {
Array(searchResults.dropFirst())
}
var isSearchActive: Bool {
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
func setLaneFilter(_ lane: Lane?) {
laneFilter = lane
reconcileSelectionForCurrentFilters()
}
func cycleToNextLane() {
let allFilters: [Lane?] = [nil] + Lane.allCases
guard let currentIndex = allFilters.firstIndex(where: { $0 == laneFilter }) else {
laneFilter = nil
return
}
laneFilter = allFilters[(currentIndex + 1) % allFilters.count]
reconcileSelectionForCurrentFilters()
}
func queueDraftAutosave() {
let snapshot = composeDraft
composeAutosaveTask?.cancel()
composeAutosaveTask = Task { [autosaveKey] in
try? await Task.sleep(for: .milliseconds(800))
guard !Task.isCancelled else { return }
if let data = try? JSONEncoder().encode(snapshot) {
UserDefaults.standard.set(data, forKey: autosaveKey)
}
} }
.count
} }
func load() async { func load() async {
@@ -85,6 +165,8 @@ final class AppViewModel {
} else { } else {
reconcileSelectionForCurrentFilters() reconcileSelectionForCurrentFilters()
} }
await refreshLiveActivity()
} catch { } catch {
errorMessage = "Unable to load mail." errorMessage = "Unable to load mail."
} }
@@ -132,11 +214,120 @@ final class AppViewModel {
} }
func startCompose() { func startCompose() {
composeDraft = ComposeDraft() if composeDraft.isEmpty {
restoreAutosavedDraft()
}
if composeDraft.isEmpty {
composeDraft = ComposeDraft(from: currentUser.email)
} else if composeDraft.from.isEmpty {
composeDraft.from = currentUser.email
}
focusedMessageRouteID = nil focusedMessageRouteID = nil
isComposing = true isComposing = true
} }
func dismissCompose() {
isComposing = false
}
func discardCompose() {
composeAutosaveTask?.cancel()
UserDefaults.standard.removeObject(forKey: autosaveKey)
composeDraft = ComposeDraft(from: currentUser.email)
isComposing = false
}
func startReply(to threadID: MailThread.ID, replyAll: Bool = false, forward: Bool = false) {
guard let thread = thread(withID: threadID) else { return }
let latestMessage = thread.latestMessage
let recipients: [MailPerson]
if forward {
recipients = []
} else if replyAll {
recipients = thread.participants.filter { $0.email != currentUser.email }
} else if let latestMessage {
recipients = [latestMessage.sender]
} else {
recipients = thread.participants.filter { $0.email != currentUser.email }
}
let replyPrefix = forward ? "Fwd:" : "Re:"
let quotedBody = latestMessage?.body
.split(separator: "\n", omittingEmptySubsequences: false)
.prefix(4)
.map { "> \($0)" }
.joined(separator: "\n") ?? ""
composeDraft = ComposeDraft(
to: recipients.map(\.email).joined(separator: ", "),
cc: "",
from: currentUser.email,
subject: thread.subject.hasPrefix(replyPrefix) ? thread.subject : "\(replyPrefix) \(thread.subject)",
body: forward || quotedBody.isEmpty ? "" : "\n\n\(quotedBody)"
)
focusedMessageRouteID = nil
isComposing = true
}
func moveThread(withID threadID: MailThread.ID, to mailbox: Mailbox) {
guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
threads[index].mailbox = mailbox
if mailbox == .trash {
threads[index].isUnread = false
}
reconcileSelectionForCurrentFilters()
}
func sendInlineReply(_ body: String, in threadID: MailThread.ID) {
let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedBody.isEmpty,
let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
let recipients = threads[index].participants.filter { $0.email != currentUser.email }
threads[index].messages.append(
MailMessage(
routeID: "\(threads[index].routeID)-reply-\(threads[index].messages.count + 1)",
sender: currentUser,
recipients: recipients,
sentAt: .now,
body: trimmedBody
)
)
threads[index].isUnread = false
openThread(withID: threadID)
}
func snoozeThread(withID threadID: MailThread.ID) {
moveThread(withID: threadID, to: .snoozed)
}
func applyScreenerDecision(_ decision: ScreenDecision, to threadID: MailThread.ID) {
guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
switch decision {
case .approve:
threads[index].mailbox = .inbox
threads[index].isScreeningCandidate = false
case .block:
threads[index].mailbox = .trash
threads[index].isUnread = false
threads[index].isScreeningCandidate = false
case .sendToPaper:
threads[index].mailbox = .inbox
threads[index].lane = .paper
threads[index].isScreeningCandidate = false
}
reconcileSelectionForCurrentFilters()
Task {
try? await service.screen(threadID: threadID, as: decision)
}
}
func openThread(withID threadID: MailThread.ID, focusedMessageRouteID: String? = nil) { func openThread(withID threadID: MailThread.ID, focusedMessageRouteID: String? = nil) {
guard let thread = thread(withID: threadID) else { return } guard let thread = thread(withID: threadID) else { return }
@@ -203,7 +394,7 @@ final class AppViewModel {
case let .compose(draft): case let .compose(draft):
focusedMessageRouteID = nil focusedMessageRouteID = nil
composeDraft = draft composeDraft = normalizedDraft(draft)
isComposing = true isComposing = true
} }
} }
@@ -221,6 +412,8 @@ final class AppViewModel {
selectedMailbox = .sent selectedMailbox = .sent
openThread(withID: sentThread.id) openThread(withID: sentThread.id)
isComposing = false isComposing = false
UserDefaults.standard.removeObject(forKey: autosaveKey)
composeDraft = ComposeDraft(from: currentUser.email)
return true return true
} catch { } catch {
errorMessage = "Unable to send message." errorMessage = "Unable to send message."
@@ -236,7 +429,11 @@ final class AppViewModel {
thread.subject, thread.subject,
thread.previewText, thread.previewText,
thread.participants.map(\.name).joined(separator: " "), thread.participants.map(\.name).joined(separator: " "),
thread.tags.joined(separator: " ") thread.tags.joined(separator: " "),
thread.lane.label,
thread.summary?.joined(separator: " ") ?? "",
thread.messages.map(\.body).joined(separator: " "),
thread.messages.flatMap(\.attachments).map(\.name).joined(separator: " ")
] ]
.joined(separator: " ") .joined(separator: " ")
.localizedLowercase .localizedLowercase
@@ -253,6 +450,57 @@ final class AppViewModel {
focusedMessageRouteID = nil focusedMessageRouteID = nil
} }
private func baseThreads(for mailbox: Mailbox) -> [MailThread] {
switch mailbox {
case .starred:
return threads.filter(\.isStarred)
case .screener:
return threads.filter(\.isScreeningCandidate)
default:
return threads.filter { $0.mailbox == mailbox }
}
}
private func score(for thread: MailThread) -> Int {
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).localizedLowercase
guard !query.isEmpty else { return 0 }
var score = 0
if thread.subject.localizedLowercase.contains(query) { score += 4 }
if thread.participants.map(\.name).joined(separator: " ").localizedLowercase.contains(query) { score += 3 }
if thread.previewText.localizedLowercase.contains(query) { score += 2 }
if thread.messages.flatMap(\.attachments).map(\.name).joined(separator: " ").localizedLowercase.contains(query) { score += 1 }
return score
}
private func normalizedDraft(_ draft: ComposeDraft) -> ComposeDraft {
var normalized = draft
if normalized.from.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
normalized.from = currentUser.email
}
return normalized
}
private func restoreAutosavedDraft() {
guard let data = UserDefaults.standard.data(forKey: autosaveKey),
let draft = try? JSONDecoder().decode(ComposeDraft.self, from: data) else {
if composeDraft.from.isEmpty {
composeDraft = ComposeDraft(from: currentUser.email)
}
return
}
composeDraft = normalizedDraft(draft)
}
private func refreshLiveActivity() async {
#if os(iOS)
if let thread = threads.first(where: { $0.mailbox == .inbox && $0.isUnread }) {
await MailNotificationActivityController.startIfNeeded(with: thread)
}
#endif
}
private func reconcileSelectionForCurrentFilters() { private func reconcileSelectionForCurrentFilters() {
if let selectedThreadID, if let selectedThreadID,
filteredThreads.contains(where: { $0.id == selectedThreadID }) { filteredThreads.contains(where: { $0.id == selectedThreadID }) {

View File

@@ -0,0 +1,168 @@
#if os(iOS) && canImport(ActivityKit) && canImport(AppIntents) && canImport(WidgetKit)
import ActivityKit
import AppIntents
import SwiftUI
import WidgetKit
struct MailNotificationActivityAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var sender: String
var initials: String
var subject: String
var preview: String
var route: String
}
var threadRouteID: String
}
struct OpenMailLiveActivityIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Open"
static var openAppWhenRun = true
@Parameter(title: "Route")
var route: String
init() {
route = "socialio://mailbox/inbox"
}
init(route: String) {
self.route = route
}
func perform() async throws -> some IntentResult {
.result()
}
}
struct SnoozeMailLiveActivityIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Snooze"
func perform() async throws -> some IntentResult {
.result()
}
}
struct MailNotificationLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: MailNotificationActivityAttributes.self) { context in
VStack(alignment: .leading, spacing: 10) {
header(context: context)
Text(context.state.preview)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack(spacing: 10) {
Button(intent: OpenMailLiveActivityIntent(route: context.state.route)) {
Text("Open")
}
.buttonStyle(.borderedProminent)
Button(intent: SnoozeMailLiveActivityIntent()) {
Text("Snooze")
}
.buttonStyle(.bordered)
}
}
.padding(.vertical, 6)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "envelope.fill")
.foregroundStyle(SIO.tint)
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.initials)
.font(.headline.weight(.semibold))
}
DynamicIslandExpandedRegion(.center) {
VStack(alignment: .leading, spacing: 4) {
header(context: context)
Text(context.state.preview)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
DynamicIslandExpandedRegion(.bottom) {
HStack(spacing: 10) {
Button(intent: OpenMailLiveActivityIntent(route: context.state.route)) {
Text("Open")
}
.buttonStyle(.borderedProminent)
Button(intent: SnoozeMailLiveActivityIntent()) {
Text("Snooze")
}
.buttonStyle(.bordered)
}
}
} compactLeading: {
Image(systemName: "envelope.fill")
} compactTrailing: {
Text(context.state.initials)
.font(.caption2.weight(.bold))
} minimal: {
Image(systemName: "envelope.fill")
}
}
}
@ViewBuilder
private func header(context: ActivityViewContext<MailNotificationActivityAttributes>) -> some View {
HStack(spacing: 10) {
Text(context.state.initials)
.font(.caption.weight(.bold))
.foregroundStyle(SIO.tint)
.frame(width: 28, height: 28)
.background(SIO.tint.opacity(0.12), in: Circle())
VStack(alignment: .leading, spacing: 2) {
Text(context.state.sender)
.font(.subheadline.weight(.semibold))
Text(context.state.subject)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
#if !WIDGET_EXTENSION
enum MailNotificationActivityController {
static func startIfNeeded(with thread: MailThread) async {
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
let attributes = MailNotificationActivityAttributes(threadRouteID: thread.routeID)
let state = MailNotificationActivityAttributes.ContentState(
sender: thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "social.io",
initials: initials(from: thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "SI"),
subject: thread.subject,
preview: thread.previewText,
route: "socialio://open?thread=\(thread.routeID)"
)
if let existing = Activity<MailNotificationActivityAttributes>.activities.first {
await existing.update(ActivityContent(state: state, staleDate: nil))
return
}
_ = try? Activity<MailNotificationActivityAttributes>.request(
attributes: attributes,
content: ActivityContent(state: state, staleDate: nil)
)
}
private static func initials(from name: String) -> String {
String(name.split(separator: " ").prefix(2).compactMap { $0.first }).uppercased()
}
}
#endif
#endif

View File

@@ -0,0 +1,13 @@
#if os(iOS) && canImport(WidgetKit) && canImport(ActivityKit) && canImport(AppIntents)
import ActivityKit
import AppIntents
import SwiftUI
import WidgetKit
@main
struct SocialIOWidgetsExtension: WidgetBundle {
var body: some Widget {
MailNotificationLiveActivity()
}
}
#endif

View File

@@ -3,17 +3,36 @@ import SwiftUI
@main @main
struct SocialIOApp: App { struct SocialIOApp: App {
@State private var model = AppViewModel() @State private var model = AppViewModel()
@AppStorage("sio.theme") private var themeRawValue = ThemePreference.system.rawValue
var body: some Scene { var body: some Scene {
WindowGroup {
MailRootView(model: model)
.tint(MailTheme.accent)
.onOpenURL { url in
model.apply(url: url)
}
}
#if os(macOS) #if os(macOS)
WindowGroup {
rootView
}
.defaultSize(width: 1440, height: 900) .defaultSize(width: 1440, height: 900)
Settings {
AppearanceSettingsView()
.frame(width: 420, height: 300)
}
#else
WindowGroup {
rootView
}
#endif #endif
} }
private var themePreference: ThemePreference {
ThemePreference(rawValue: themeRawValue) ?? .system
}
private var rootView: some View {
MailRootView(model: model)
.tint(SIO.tint)
.preferredColorScheme(themePreference.colorScheme)
.onOpenURL { url in
model.apply(url: url)
}
}
} }

View File

@@ -0,0 +1,31 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>social.io Widgets</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>MinimumOSVersion</key>
<string>$(IPHONEOS_DEPLOYMENT_TARGET)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,37 @@
import SwiftUI
struct AISummaryCard: View {
let count: Int
let bullets: [String]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 7) {
Image(systemName: "sparkles")
Text("SUMMARY")
Text(".")
Text("\(count) \(messageLabel.uppercased())")
}
.font(.caption.weight(.semibold))
.foregroundStyle(SIO.tint)
ForEach(Array(bullets.enumerated()), id: \.offset) { _, bullet in
HStack(alignment: .top, spacing: 8) {
Circle()
.fill(SIO.tint)
.frame(width: 6, height: 6)
.padding(.top, 6)
Text(bullet)
.font(.subheadline)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.padding(16)
.sioCardBackground(tint: SIO.tint)
}
private var messageLabel: String {
count == 1 ? "message" : "messages"
}
}

View File

@@ -0,0 +1,21 @@
import SwiftUI
struct AvatarView: View {
let name: String
var color: Color = SIO.tint
var size: CGFloat = 30
var body: some View {
Text(initials)
.font(.system(size: size * 0.42, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.frame(width: size, height: size)
.background(color, in: Circle())
.overlay(Circle().strokeBorder(.white.opacity(0.16), lineWidth: 1))
}
private var initials: String {
let parts = name.split(separator: " ")
return String(parts.prefix(2).compactMap { $0.first }).uppercased()
}
}

View File

@@ -0,0 +1,57 @@
import SwiftUI
public struct PrimaryActionStyle: ButtonStyle {
public init() {}
public func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 11)
.frame(minHeight: 44)
.background(
RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous)
.fill(SIO.tint.opacity(configuration.isPressed ? 0.82 : 1))
)
.opacity(configuration.isPressed ? 0.94 : 1)
}
}
public struct SecondaryActionStyle: ButtonStyle {
public init() {}
public func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
.padding(.horizontal, 16)
.padding(.vertical, 11)
.frame(minHeight: 44)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
)
.opacity(configuration.isPressed ? 0.9 : 1)
}
}
public struct DestructiveStyle: ButtonStyle {
public init() {}
public func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline.weight(.semibold))
.foregroundStyle(.red)
.padding(.horizontal, 16)
.padding(.vertical, 11)
.frame(minHeight: 44)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous)
.strokeBorder(Color.red.opacity(configuration.isPressed ? 0.35 : 0.18), lineWidth: 1)
)
.opacity(configuration.isPressed ? 0.9 : 1)
}
}

View File

@@ -0,0 +1,23 @@
import SwiftUI
public extension View {
@ViewBuilder
func sioGlassChrome() -> some View {
if #available(iOS 26.0, macOS 26.0, *) {
self.glassEffect(.regular)
} else {
self.background(.regularMaterial)
}
}
@ViewBuilder
func sioGlassSurface<S: Shape>(in shape: S, tint: Color? = nil) -> some View {
if #available(iOS 26.0, macOS 26.0, *) {
self.glassEffect(Glass.regular.tint(tint), in: shape)
} else {
self.background(.regularMaterial, in: shape)
.overlay(shape.stroke(Color.primary.opacity(0.08), lineWidth: 1))
.overlay(shape.fill((tint ?? .clear).opacity(0.08)))
}
}
}

View File

@@ -0,0 +1,25 @@
import SwiftUI
#if os(iOS)
import UIKit
#endif
enum Haptics {
static func selection() {
#if os(iOS)
UISelectionFeedbackGenerator().selectionChanged()
#endif
}
static func success() {
#if os(iOS)
UINotificationFeedbackGenerator().notificationOccurred(.success)
#endif
}
static func warning() {
#if os(iOS)
UINotificationFeedbackGenerator().notificationOccurred(.warning)
#endif
}
}

View File

@@ -0,0 +1,19 @@
import SwiftUI
struct KeyboardHint: View {
let title: String
var body: some View {
Text(title)
.font(.caption2.weight(.semibold))
.monospaced()
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
)
}
}

View File

@@ -0,0 +1,24 @@
import SwiftUI
struct LaneChip: View {
let lane: Lane
var count: Int?
var body: some View {
HStack(spacing: 5) {
Circle()
.fill(lane.color)
.frame(width: 7, height: 7)
Text(lane.label.uppercased())
if let count {
Text(count, format: .number)
.foregroundStyle(lane.color.opacity(0.8))
}
}
.font(.caption2.weight(.semibold))
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(lane.color.opacity(0.14), in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous))
.foregroundStyle(lane.color)
}
}

View File

@@ -0,0 +1,136 @@
import SwiftUI
public enum SIO {
public static let tint = Color("SIOTint")
public static let laneFeed = Color("LaneFeed")
public static let lanePaper = Color("LanePaper")
public static let lanePeople = Color("LanePeople")
public static let cardRadius: CGFloat = 14
public static let controlRadius: CGFloat = 10
public static let chipRadius: CGFloat = 6
}
public enum Lane: String, CaseIterable, Codable, Identifiable {
case feed
case paper
case people
public var id: String { rawValue }
public var label: String {
switch self {
case .feed: "Feed"
case .paper: "Paper"
case .people: "People"
}
}
public var color: Color {
switch self {
case .feed: SIO.laneFeed
case .paper: SIO.lanePaper
case .people: SIO.lanePeople
}
}
}
public enum ThreadRowDensity: String, CaseIterable, Identifiable {
case compact
case cozy
case comfortable
public var id: String { rawValue }
public var avatarSize: CGFloat {
switch self {
case .compact: 24
case .cozy: 30
case .comfortable: 30
}
}
public var previewLineLimit: Int {
switch self {
case .compact, .cozy: 1
case .comfortable: 2
}
}
public var rowPadding: CGFloat {
switch self {
case .compact: 10
case .cozy: 12
case .comfortable: 14
}
}
public var showsMetaChips: Bool {
self == .comfortable
}
}
public enum ThemePreference: String, CaseIterable, Identifiable {
case system
case light
case dark
public var id: String { rawValue }
public var label: String {
switch self {
case .system: "System"
case .light: "Light"
case .dark: "Dark"
}
}
public var colorScheme: ColorScheme? {
switch self {
case .system: nil
case .light: .light
case .dark: .dark
}
}
}
public enum ReadingPanePreference: String, CaseIterable, Identifiable {
case right
case bottom
case off
public var id: String { rawValue }
public var label: String {
switch self {
case .right: "Right"
case .bottom: "Bottom"
case .off: "Off"
}
}
}
public extension View {
func sioCardBackground(tint: Color? = nil, cornerRadius: CGFloat = SIO.cardRadius) -> some View {
background(.regularMaterial, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill((tint ?? Color.clear).opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
)
}
func sioSoftSelection(_ isSelected: Bool) -> some View {
background(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.fill(isSelected ? SIO.tint.opacity(0.12) : Color.clear)
)
}
func sioProse() -> some View {
font(.system(size: 15.5))
.lineSpacing(6)
}
}

View File

@@ -3,9 +3,12 @@ import Foundation
enum Mailbox: String, CaseIterable, Identifiable, Codable { enum Mailbox: String, CaseIterable, Identifiable, Codable {
case inbox case inbox
case starred case starred
case snoozed
case screener
case sent case sent
case drafts case drafts
case archive case archive
case trash
var id: String { rawValue } var id: String { rawValue }
@@ -13,9 +16,12 @@ enum Mailbox: String, CaseIterable, Identifiable, Codable {
switch self { switch self {
case .inbox: "Inbox" case .inbox: "Inbox"
case .starred: "Starred" case .starred: "Starred"
case .snoozed: "Snoozed"
case .screener: "The Screener"
case .sent: "Sent" case .sent: "Sent"
case .drafts: "Drafts" case .drafts: "Drafts"
case .archive: "Archive" case .archive: "Archive"
case .trash: "Trash"
} }
} }
@@ -23,9 +29,12 @@ enum Mailbox: String, CaseIterable, Identifiable, Codable {
switch self { switch self {
case .inbox: "tray.full" case .inbox: "tray.full"
case .starred: "star" case .starred: "star"
case .snoozed: "clock.badge"
case .screener: "line.3.horizontal.decrease.circle"
case .sent: "paperplane" case .sent: "paperplane"
case .drafts: "doc.text" case .drafts: "doc.text"
case .archive: "archivebox" case .archive: "archivebox"
case .trash: "trash"
} }
} }
} }
@@ -50,6 +59,7 @@ struct MailMessage: Identifiable, Hashable, Codable {
let sentAt: Date let sentAt: Date
let body: String let body: String
let isDraft: Bool let isDraft: Bool
let attachments: [MailAttachment]
init( init(
id: UUID = UUID(), id: UUID = UUID(),
@@ -58,7 +68,8 @@ struct MailMessage: Identifiable, Hashable, Codable {
recipients: [MailPerson], recipients: [MailPerson],
sentAt: Date, sentAt: Date,
body: String, body: String,
isDraft: Bool = false isDraft: Bool = false,
attachments: [MailAttachment] = []
) { ) {
self.id = id self.id = id
self.routeID = routeID self.routeID = routeID
@@ -67,6 +78,19 @@ struct MailMessage: Identifiable, Hashable, Codable {
self.sentAt = sentAt self.sentAt = sentAt
self.body = body self.body = body
self.isDraft = isDraft self.isDraft = isDraft
self.attachments = attachments
}
}
struct MailAttachment: Identifiable, Hashable, Codable {
let id: UUID
let name: String
let size: String
init(id: UUID = UUID(), name: String, size: String) {
self.id = id
self.name = name
self.size = size
} }
} }
@@ -80,6 +104,9 @@ struct MailThread: Identifiable, Hashable, Codable {
var isUnread: Bool var isUnread: Bool
var isStarred: Bool var isStarred: Bool
var tags: [String] var tags: [String]
var lane: Lane
var summary: [String]?
var isScreeningCandidate: Bool
init( init(
id: UUID = UUID(), id: UUID = UUID(),
@@ -90,7 +117,10 @@ struct MailThread: Identifiable, Hashable, Codable {
messages: [MailMessage], messages: [MailMessage],
isUnread: Bool, isUnread: Bool,
isStarred: Bool, isStarred: Bool,
tags: [String] = [] tags: [String] = [],
lane: Lane = .people,
summary: [String]? = nil,
isScreeningCandidate: Bool = false
) { ) {
self.id = id self.id = id
self.routeID = routeID self.routeID = routeID
@@ -101,6 +131,9 @@ struct MailThread: Identifiable, Hashable, Codable {
self.isUnread = isUnread self.isUnread = isUnread
self.isStarred = isStarred self.isStarred = isStarred
self.tags = tags self.tags = tags
self.lane = lane
self.summary = summary
self.isScreeningCandidate = isScreeningCandidate
} }
var latestMessage: MailMessage? { var latestMessage: MailMessage? {
@@ -114,10 +147,27 @@ struct MailThread: Identifiable, Hashable, Codable {
var lastUpdated: Date { var lastUpdated: Date {
latestMessage?.sentAt ?? .distantPast latestMessage?.sentAt ?? .distantPast
} }
var messageCount: Int {
messages.count
}
var hasAttachments: Bool {
messages.contains { !$0.attachments.isEmpty }
}
} }
struct ComposeDraft: Equatable { struct ComposeDraft: Equatable, Codable {
var to = "" var to = ""
var cc = ""
var from = ""
var subject = "" var subject = ""
var body = "" var body = ""
var isEmpty: Bool {
to.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
cc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
} }

View File

@@ -3,6 +3,19 @@ import Foundation
protocol MailServicing { protocol MailServicing {
func loadThreads() async throws -> [MailThread] func loadThreads() async throws -> [MailThread]
func send(draft: ComposeDraft) async throws -> MailThread func send(draft: ComposeDraft) async throws -> MailThread
func screen(threadID: MailThread.ID, as decision: ScreenDecision) async throws
}
extension MailServicing {
func screen(threadID: MailThread.ID, as decision: ScreenDecision) async throws {}
}
enum ScreenDecision: String, CaseIterable, Identifiable {
case approve
case block
case sendToPaper
var id: String { rawValue }
} }
struct MockMailService: MailServicing { struct MockMailService: MailServicing {
@@ -47,140 +60,311 @@ struct MockMailService: MailServicing {
messages: [message], messages: [message],
isUnread: false, isUnread: false,
isStarred: false, isStarred: false,
tags: ["Sent"] tags: ["Sent"],
lane: .people
) )
} }
func screen(threadID: MailThread.ID, as decision: ScreenDecision) async throws {
try await Task.sleep(for: .milliseconds(40))
}
func previewThreads() -> [MailThread] {
seededThreads.sorted { $0.lastUpdated > $1.lastUpdated }
}
private var seededThreads: [MailThread] { private var seededThreads: [MailThread] {
let alex = MailPerson(name: "Alex Rivera", email: "alex@social.io") let aiko = MailPerson(name: "Aiko Tanaka", email: "aiko@social.io")
let nora = MailPerson(name: "Nora Chen", email: "nora@social.io") let appleDeveloper = MailPerson(name: "Apple Developer", email: "developer@apple.com")
let tanya = MailPerson(name: "Tanya Hall", email: "tanya@design.social.io") let figma = MailPerson(name: "Figma", email: "no-reply@figma.com")
let ops = MailPerson(name: "Ops Bot", email: "ops@social.io") let github = MailPerson(name: "GitHub", email: "noreply@github.com")
let investor = MailPerson(name: "Mina Park", email: "mina@northshore.vc") let lena = MailPerson(name: "Lena Park", email: "lena@orbitlabs.io")
let linear = MailPerson(name: "Linear", email: "updates@linear.app")
let marcos = MailPerson(name: "Marcos Vidal", email: "marcos@northbeam.vc")
let mom = MailPerson(name: "Mom", email: "mom@example.com")
let priya = MailPerson(name: "Priya Shah", email: "priya@social.io")
let socialTeam = MailPerson(name: "social.io team", email: "team@social.io")
let stripe = MailPerson(name: "Stripe", email: "receipts@stripe.com")
func hoursAgo(_ hours: Double) -> Date {
.now.addingTimeInterval(-(hours * 60 * 60))
}
return [ return [
MailThread( MailThread(
routeID: "launch-copy", routeID: "launch-copy",
mailbox: .inbox, mailbox: .inbox,
subject: "Launch copy for the onboarding flow", subject: "Launch copy review - second pass",
participants: [tanya, me], participants: [lena, aiko, me],
messages: [ messages: [
MailMessage( MailMessage(
routeID: "launch-copy-1", routeID: "launch-copy-1",
sender: tanya, sender: lena,
recipients: [me], recipients: [me, aiko],
sentAt: .now.addingTimeInterval(-3600 * 2), sentAt: hoursAgo(25),
body: "I tightened the onboarding copy and added a warmer empty-state tone. If you're good with it, I can hand the strings to the app team today." body: "Hi Phil - pulled together the changes from yesterday. Hero is the big one; the rest are mostly trims.\n\nThe three lanes framing landed well in the user tests, so I leaned into it harder.\n\nTwo open questions:\n1. Should we keep the screener mention up top, or move it down so we lead with split inbox?\n2. The closing CTA still feels long. I attached two trims.\n\nWould love a 15-minute sync before Tuesday's ship.",
attachments: [
MailAttachment(name: "cta-trims.pdf", size: "184 KB")
]
), ),
MailMessage( MailMessage(
routeID: "launch-copy-2", routeID: "launch-copy-2",
sender: aiko,
recipients: [lena, me],
sentAt: hoursAgo(17),
body: "Hero is great. Lead with split inbox first and keep the screener second. The shorter CTA also feels better."
),
MailMessage(
routeID: "launch-copy-3",
sender: me, sender: me,
recipients: [tanya], recipients: [lena, aiko],
sentAt: .now.addingTimeInterval(-3600), sentAt: hoursAgo(12),
body: "Looks strong. Let's keep the playful language on iPhone and simplify the desktop first-run copy a bit." body: "Agree on both points. Let's keep the copy calmer and make split inbox the first thing people understand."
),
MailMessage(
routeID: "launch-copy-4",
sender: lena,
recipients: [me, aiko],
sentAt: hoursAgo(5),
body: "Perfect. I made both edits and dropped in the latest screenshots as well. Monday 2pm works for me if that still does for you."
) )
], ],
isUnread: true, isUnread: true,
isStarred: true, isStarred: true,
tags: ["Design", "Launch"] tags: ["Orbit Labs"],
lane: .people,
summary: [
"Lena rewrote the hero and the three lanes section after Tuesday's feedback.",
"Two open questions remain: where to introduce the screener and which shorter CTA trim to ship.",
"A short sync before Tuesday is the last blocker."
]
), ),
MailThread( MailThread(
routeID: "daily-sync-status", routeID: "daily-sync-status",
mailbox: .inbox, mailbox: .inbox,
subject: "Daily inbox sync status", subject: "Daily sync - 14 issues moved to Done",
participants: [ops, me], participants: [linear, me],
messages: [ messages: [
MailMessage( MailMessage(
routeID: "daily-sync-status-1", routeID: "daily-sync-status-1",
sender: ops, sender: linear,
recipients: [me], recipients: [me],
sentAt: .now.addingTimeInterval(-3600 * 4), sentAt: hoursAgo(8),
body: "Mock sync complete. 1,284 messages mirrored from staging. Attachment fetch remains disabled in the sandbox profile." body: "Mobile: iOS keyboard regression fixed. Inbox: reconnect backoff landed. Compose autosave race condition is queued for review."
)
],
isUnread: false,
isStarred: false,
tags: ["System"]
),
MailThread(
routeID: "investor-update",
mailbox: .inbox,
subject: "Investor update before next Friday",
participants: [investor, me],
messages: [
MailMessage(
routeID: "investor-update-1",
sender: investor,
recipients: [me],
sentAt: .now.addingTimeInterval(-3600 * 26),
body: "Could you send a concise product update and a few screenshots of the new mail experience before Friday? Interested in how you are differentiating from commodity inboxes."
) )
], ],
isUnread: true, isUnread: true,
isStarred: false, isStarred: false,
tags: ["External"] tags: ["System"],
lane: .feed
),
MailThread(
routeID: "investor-update",
mailbox: .inbox,
subject: "Re: Q1 investor update - looks great, two small notes",
participants: [marcos, me],
messages: [
MailMessage(
routeID: "investor-update-1",
sender: marcos,
recipients: [me],
sentAt: hoursAgo(34),
body: "Strong narrative overall. Two things: the ARR chart on slide 8 needs a zero baseline, and the differentiation story should stay concrete. The product story is strongest when you anchor it in the split inbox and command-first workflows.",
attachments: [
MailAttachment(name: "Q1-investor-update.pdf", size: "1.8 MB")
]
),
MailMessage(
routeID: "investor-update-2",
sender: me,
recipients: [marcos],
sentAt: hoursAgo(31),
body: "Helpful. I will tighten the chart and rework the story around calmer triage instead of generic AI claims."
)
],
isUnread: true,
isStarred: true,
tags: ["Board & investors"],
lane: .people,
summary: [
"Marcos likes the overall update but wants a tighter chart and cleaner positioning.",
"He thinks the strongest differentiation story is split inbox plus keyboard-first workflow.",
"A revised deck with updated screenshots is the next step."
]
), ),
MailThread( MailThread(
routeID: "search-ranking-polish", routeID: "search-ranking-polish",
mailbox: .sent, mailbox: .sent,
subject: "Re: Search ranking polish", subject: "Search ranking polish - final round before ship",
participants: [alex, me], participants: [priya, me],
messages: [ messages: [
MailMessage( MailMessage(
routeID: "search-ranking-polish-1", routeID: "search-ranking-polish-1",
sender: alex, sender: priya,
recipients: [me], recipients: [me],
sentAt: .now.addingTimeInterval(-3600 * 30), sentAt: hoursAgo(40),
body: "The current search sort is useful, but I still feel too much recency over intent." body: "I tagged the 12 queries that still feel off. Most of them are personal-name ambiguity and a few intent misses around review threads."
), ),
MailMessage( MailMessage(
routeID: "search-ranking-polish-2", routeID: "search-ranking-polish-2",
sender: me, sender: me,
recipients: [alex], recipients: [priya],
sentAt: .now.addingTimeInterval(-3600 * 28), sentAt: hoursAgo(28),
body: "Agreed. I want us to bias toward active collaborators and threads with lightweight action language like review, approve, or ship." body: "Agree. We should bias harder toward active collaborators and action language like review, approve, and ship instead of pure recency."
) )
], ],
isUnread: false, isUnread: false,
isStarred: false, isStarred: false,
tags: ["Search"] tags: ["Search"],
), lane: .people
MailThread(
routeID: "welcome-to-socialio",
mailbox: .drafts,
subject: "Welcome to social.io mail",
participants: [me, nora],
messages: [
MailMessage(
routeID: "welcome-to-socialio-1",
sender: me,
recipients: [nora],
sentAt: .now.addingTimeInterval(-3600 * 6),
body: "Thanks for joining the early design partner group. Here is a quick overview of our calm, collaborative take on email...",
isDraft: true
)
],
isUnread: false,
isStarred: false,
tags: ["Draft"]
), ),
MailThread( MailThread(
routeID: "roadmap-notes", routeID: "roadmap-notes",
mailbox: .archive, mailbox: .archive,
subject: "Roadmap notes from product sync", subject: "Roadmap notes from this morning",
participants: [nora, alex, me], participants: [aiko, me],
messages: [ messages: [
MailMessage( MailMessage(
routeID: "roadmap-notes-1", routeID: "roadmap-notes-1",
sender: nora, sender: aiko,
recipients: [alex, me], recipients: [me],
sentAt: .now.addingTimeInterval(-3600 * 72), sentAt: hoursAgo(46),
body: "Captured the big roadmap themes: faster triage, identity-rich threads, and calmer notifications. I archived the raw notes after cleanup." body: "Captured everything from the whiteboard and split it into doing, next, and later. The biggest open question is when we bring the screener into the main story instead of leaving it as a power-user feature.",
attachments: [
MailAttachment(name: "roadmap-notes.pdf", size: "412 KB")
]
)
],
isUnread: false,
isStarred: false,
tags: [],
lane: .people,
summary: [
"The roadmap is grouped into doing, next, and later for the next planning pass.",
"The biggest open product question is when the screener should become a core story instead of a hidden power-user tool.",
"This archived thread is the clean summary version of the whiteboard session."
]
),
MailThread(
routeID: "stripe-receipt",
mailbox: .inbox,
subject: "Receipt for EUR 240.00 - social-io-prod",
participants: [stripe, me],
messages: [
MailMessage(
routeID: "stripe-receipt-1",
sender: stripe,
recipients: [me],
sentAt: hoursAgo(72),
body: "Your payment of EUR 240.00 to AWS Europe SARL was successful. View invoice INV-04219 in your dashboard.",
attachments: [
MailAttachment(name: "INV-04219.pdf", size: "96 KB")
]
)
],
isUnread: false,
isStarred: false,
tags: ["Receipts 2026"],
lane: .paper
),
MailThread(
routeID: "figma-comment",
mailbox: .inbox,
subject: "Lena commented on Inbox - iPad split",
participants: [figma, me],
messages: [
MailMessage(
routeID: "figma-comment-1",
sender: figma,
recipients: [me],
sentAt: hoursAgo(76),
body: "Love the lane chips. Can we try them at 10pt instead of 11pt? They fight the row title a little at this size."
)
],
isUnread: false,
isStarred: false,
tags: ["Orbit Labs"],
lane: .feed
),
MailThread(
routeID: "github-pr",
mailbox: .inbox,
subject: "[social-io/web] PR #842 ready for review - Compose autosave",
participants: [github, me],
messages: [
MailMessage(
routeID: "github-pr-1",
sender: github,
recipients: [me],
sentAt: hoursAgo(82),
body: "priya-shah opened a pull request: Persist compose draft to IndexedDB every 800ms and restore on cold open. 14 files changed, plus 382 and minus 97."
)
],
isUnread: false,
isStarred: false,
tags: ["Search"],
lane: .feed
),
MailThread(
routeID: "mom-photos",
mailbox: .inbox,
subject: "Photos from the weekend",
participants: [mom, me],
messages: [
MailMessage(
routeID: "mom-photos-1",
sender: mom,
recipients: [me],
sentAt: hoursAgo(90),
body: "Hi liebling - I tried the share thing you set up. Did the photos arrive? There are 23 of them, mostly the dog with the new yellow flowers.",
attachments: [
MailAttachment(name: "weekend-photos.zip", size: "24 MB")
]
) )
], ],
isUnread: false, isUnread: false,
isStarred: true, isStarred: true,
tags: ["Product"] tags: [],
lane: .people
),
MailThread(
routeID: "apple-developer",
mailbox: .inbox,
subject: "Your annual membership renews on April 30",
participants: [appleDeveloper, me],
messages: [
MailMessage(
routeID: "apple-developer-1",
sender: appleDeveloper,
recipients: [me],
sentAt: hoursAgo(132),
body: "Your Apple Developer Program membership will automatically renew on April 30. Manage your membership in App Store Connect."
)
],
isUnread: false,
isStarred: false,
tags: ["Receipts 2026"],
lane: .paper
),
MailThread(
routeID: "welcome-to-socialio",
mailbox: .screener,
subject: "Welcome to social.io - start here",
participants: [socialTeam, me],
messages: [
MailMessage(
routeID: "welcome-to-socialio-1",
sender: socialTeam,
recipients: [me],
sentAt: hoursAgo(18),
body: "Three things to know: split inbox is automatic, command-K does almost everything, and snoozed mail comes back when you are actually free."
)
],
isUnread: true,
isStarred: true,
tags: [],
lane: .people,
isScreeningCandidate: true
) )
] ]
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
import SwiftUI
enum SIO {
static let tint = Color(red: 0.184, green: 0.420, blue: 1.0)
static let laneFeed = Color(red: 0.184, green: 0.420, blue: 1.0)
static let lanePaper = Color(red: 1.0, green: 0.624, blue: 0.039)
static let lanePeople = Color(red: 0.188, green: 0.819, blue: 0.345)
}
enum Lane: String, CaseIterable, Codable, Identifiable {
case feed
case paper
case people
var id: String { rawValue }
var label: String {
switch self {
case .feed: "Feed"
case .paper: "Paper"
case .people: "People"
}
}
var color: Color {
switch self {
case .feed: SIO.laneFeed
case .paper: SIO.lanePaper
case .people: SIO.lanePeople
}
}
}

View File

@@ -0,0 +1,14 @@
import SwiftUI
@main
struct SocialIOWatchApp: App {
@State private var store = WatchInboxStore()
var body: some Scene {
WindowGroup {
NavigationStack {
WatchInboxView(store: store)
}
}
}
}

View File

@@ -0,0 +1,201 @@
import Foundation
import Observation
import SwiftUI
@MainActor
@Observable
final class WatchInboxStore {
var threads: [MailThread] = []
var selectedThreadID: MailThread.ID?
private let service = MockMailService()
var visibleThreads: [MailThread] {
Array(
threads
.filter { $0.mailbox == .inbox }
.sorted { $0.lastUpdated > $1.lastUpdated }
.prefix(4)
)
}
var selectedThread: MailThread? {
visibleThreads.first(where: { $0.id == selectedThreadID }) ?? visibleThreads.first
}
func load() async {
guard threads.isEmpty else { return }
threads = (try? await service.loadThreads()) ?? service.previewThreads()
selectedThreadID = visibleThreads.first?.id
}
func select(_ thread: MailThread) {
selectedThreadID = thread.id
}
func archive(_ thread: MailThread) {
guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
threads[index].mailbox = .archive
if selectedThreadID == thread.id {
selectedThreadID = visibleThreads.first?.id
}
}
}
struct WatchInboxView: View {
@Bindable var store: WatchInboxStore
var body: some View {
List(store.visibleThreads) { thread in
NavigationLink {
WatchThreadView(thread: thread, store: store)
} label: {
WatchInboxRow(
thread: thread,
isHighlighted: thread.id == (store.selectedThread?.id ?? store.visibleThreads.first?.id)
)
}
.buttonStyle(.plain)
.simultaneousGesture(TapGesture().onEnded {
store.select(thread)
})
}
.listStyle(.carousel)
.navigationTitle("Inbox")
.task {
await store.load()
}
}
}
private struct WatchInboxRow: View {
let thread: MailThread
let isHighlighted: Bool
var body: some View {
HStack(spacing: 10) {
AvatarCircle(name: senderName, color: thread.lane.color)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(senderName)
.font(.headline)
.lineLimit(1)
if thread.isUnread {
Circle()
.fill(SIO.tint)
.frame(width: 6, height: 6)
}
}
Text(thread.subject)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.background(isHighlighted ? SIO.tint.opacity(0.12) : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var senderName: String {
thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "Unknown"
}
}
struct WatchThreadView: View {
let thread: MailThread
@Bindable var store: WatchInboxStore
@Environment(\.openURL) private var openURL
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
AvatarCircle(name: senderName, color: thread.lane.color)
VStack(alignment: .leading, spacing: 2) {
Text(senderName)
.font(.headline)
Text(thread.lane.label)
.font(.caption2)
.foregroundStyle(thread.lane.color)
}
}
Text(thread.subject)
.font(.headline)
.lineLimit(2)
Text(thread.previewText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(4)
Button("Reply") {
openURL(URL(string: "socialio://compose?to=\(replyTarget)&subject=Re:%20\(thread.subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? thread.subject)")!)
}
.buttonStyle(.borderedProminent)
.tint(SIO.tint)
Button {
store.archive(thread)
} label: {
Image(systemName: "archivebox")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
.padding(.horizontal, 6)
}
}
private var senderName: String {
thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "Unknown"
}
private var replyTarget: String {
(thread.latestMessage?.sender.email ?? thread.participants.first?.email ?? "hello@social.io")
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "hello@social.io"
}
}
private struct AvatarCircle: View {
let name: String
let color: Color
var body: some View {
Text(initials)
.font(.system(size: 9, weight: .semibold, design: .rounded))
.foregroundStyle(color)
.frame(width: 22, height: 22)
.background(color.opacity(0.14), in: Circle())
}
private var initials: String {
String(name.split(separator: " ").prefix(2).compactMap { $0.first })
.uppercased()
}
}
#Preview("Watch Inbox") {
NavigationStack {
WatchInboxView(store: previewStore())
}
}
#Preview("Watch Thread") {
NavigationStack {
if let thread = previewStore().visibleThreads.first {
WatchThreadView(thread: thread, store: previewStore())
}
}
}
@MainActor
private func previewStore() -> WatchInboxStore {
let store = WatchInboxStore()
store.threads = MockMailService().previewThreads()
store.selectedThreadID = store.visibleThreads.first?.id
return store
}

View File

@@ -0,0 +1,29 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>MinimumOSVersion</key>
<string>$(WATCHOS_DEPLOYMENT_TARGET)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,82 @@
import SwiftUI
import WidgetKit
struct WatchUnreadEntry: TimelineEntry {
let date: Date
let unreadCount: Int
}
struct WatchUnreadProvider: TimelineProvider {
func placeholder(in context: Context) -> WatchUnreadEntry {
WatchUnreadEntry(date: .now, unreadCount: 3)
}
func getSnapshot(in context: Context, completion: @escaping (WatchUnreadEntry) -> Void) {
completion(makeEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchUnreadEntry>) -> Void) {
completion(Timeline(entries: [makeEntry()], policy: .after(.now.addingTimeInterval(900))))
}
private func makeEntry() -> WatchUnreadEntry {
let unreadCount = MockMailService().previewThreads().filter { $0.mailbox == .inbox && $0.isUnread }.count
return WatchUnreadEntry(date: .now, unreadCount: unreadCount)
}
}
struct WatchUnreadComplication: Widget {
let kind = "SocialIOWatchUnreadComplication"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: WatchUnreadProvider()) { entry in
WatchUnreadComplicationView(entry: entry)
}
.configurationDisplayName("social.io Inbox")
.description("Unread social.io mail at a glance.")
.supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryCorner])
}
}
private struct WatchUnreadComplicationView: View {
let entry: WatchUnreadEntry
var body: some View {
switch widgetFamily {
case .accessoryCircular:
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 2) {
Image(systemName: "envelope.fill")
Text(entry.unreadCount, format: .number)
.font(.caption2.weight(.bold))
}
}
.widgetURL(URL(string: "socialio://mailbox/inbox"))
case .accessoryCorner:
Text("\(entry.unreadCount)")
.font(.caption.weight(.bold))
.widgetURL(URL(string: "socialio://mailbox/inbox"))
default:
VStack(alignment: .leading, spacing: 4) {
Text("social.io")
.font(.caption2)
.foregroundStyle(.secondary)
Text("\(entry.unreadCount) unread")
.font(.caption.weight(.semibold))
}
.widgetURL(URL(string: "socialio://mailbox/inbox"))
}
}
@Environment(\.widgetFamily) private var widgetFamily
}
@main
struct SocialIOWatchWidgets: WidgetBundle {
var body: some Widget {
WatchUnreadComplication()
}
}